mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-15 20:13:50 +00:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2294c6e276 | |||
| d4f177f24b | |||
| 8569048c3f | |||
| acb400231b | |||
| 3f037bf64e | |||
| 5336d14c54 | |||
| cda0a1232a | |||
| 22940c53df | |||
| acfdb75767 | |||
| 287fb78c0d | |||
| 565d709904 | |||
| 10b5747016 | |||
| 15d2f04754 | |||
| cf5771396c | |||
| 13dd60428d | |||
| b333ec2bef | |||
| 319520e1ca | |||
| 6268eacd6c | |||
| 4cbe02b8a0 | |||
| 9c01db944b | |||
| 7f9958cb24 | |||
| 575b154ef4 | |||
| 0189b20e0c | |||
| ffde37ed15 | |||
| 5a2b10b54b | |||
| 1113d509a8 | |||
| 8fcf650069 | |||
| c25233f58e | |||
| 180325449c | |||
| 1fbfb5c3d2 | |||
| eae3ee99e8 | |||
| 87cd2b6c87 | |||
| 0ccf04fcbc | |||
| 2174336512 | |||
| fb05f0b889 | |||
| 150c882827 | |||
| 0d8bc92dee | |||
| 3b77f926e6 | |||
| 42663e7032 | |||
| 05a50a0ce0 | |||
| d62c92e527 | |||
| 93f41de185 | |||
| 77254c85fd | |||
| 8788b90271 | |||
| 61194d7132 | |||
| b3c774b84c | |||
| 5c5fc245b3 | |||
| 981917cd6f | |||
| 5de84d2ec1 | |||
| c77dc6ac47 | |||
| 62f7525238 | |||
| c3125a0db1 | |||
| 3b1ffb21a6 | |||
| 829d8cc031 | |||
| 6cdc0f0bde | |||
| 4137f85190 | |||
| 58842c6f1e | |||
| b2bfd12a35 | |||
| 6a10ab5983 | |||
| 7ec5c5bb38 | |||
| 4f4f122702 | |||
| 19f9b077d6 | |||
| a8c702123e | |||
| af4b7c4e4c | |||
| dc7f6ba18e | |||
| 0be329ff06 | |||
| 39701b2155 | |||
| ed6dc6db74 | |||
| eec6e2041c | |||
| df8af5146f | |||
| 7a91eb5173 | |||
| 57a800ef2c | |||
| 3cd3200d1e | |||
| d329989230 | |||
| 026524a1b3 | |||
| a5a14a93ef | |||
| f032005d8f | |||
| 86ced6e7f9 | |||
| 8a251f775a | |||
| ad30f04078 | |||
| 506ceefe26 | |||
| de565bb965 | |||
| 3bbbd340c5 | |||
| d62b17ad65 | |||
| 259d83a71e | |||
| 26426f9804 | |||
| 1250261afe | |||
| f1f2fe99d7 | |||
| 3189d8932b | |||
| fe1f49090c | |||
| 9253926c7d | |||
| ddac66e3a9 | |||
| 3d89222eec | |||
| 2ef478e4b1 | |||
| 67b748b88e | |||
| c23de036c7 | |||
| 478ff600d3 | |||
| cb1321825d | |||
| 464f365085 | |||
| 7de7ccf9aa | |||
| 8540a2c50f | |||
| d49f4b2c38 | |||
| b83b02fd12 | |||
| 550d9bac18 | |||
| 21fe0cdab8 | |||
| 00fb76ca4f | |||
| c4865337ee | |||
| 33409bc375 | |||
| 90875517da | |||
| 4be732005e | |||
| 7c6afd169c | |||
| e4ef376064 | |||
| 8bc8399b5f | |||
| f28f250fab | |||
| 08a6f6d7cc | |||
| 243dc883ac | |||
| 3a5e961d54 | |||
| 6ed1b25583 | |||
| 867f0f43f6 | |||
| a16959d7ba | |||
| d6baf11a7c | |||
| f20e856d44 | |||
| ec8adbf807 | |||
| 5b7eafc294 | |||
| 6959bcdd04 | |||
| 090224fc70 | |||
| 24a70f123a |
+13
-1
@@ -3,4 +3,16 @@ PUBLIC_SUPABASE_ANON_KEY=""
|
|||||||
SUPABASE_SERVICE_ROLE_KEY=""
|
SUPABASE_SERVICE_ROLE_KEY=""
|
||||||
STRIPE_SECRET_KEY=""
|
STRIPE_SECRET_KEY=""
|
||||||
STRIPE_ENDPOINT_SECRET=""
|
STRIPE_ENDPOINT_SECRET=""
|
||||||
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
|
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
|
||||||
|
VITE_AXIOM_DATASET=""
|
||||||
|
VITE_AXIOM_TOKEN=""
|
||||||
|
# Brevo API configuration for transactional emails (account closure notifications)
|
||||||
|
VITE_BREVO_API_KEY=""
|
||||||
|
# Default sender email for system notifications
|
||||||
|
VITE_BREVO_SENDER_EMAIL="noreply@example.com"
|
||||||
|
# Display name for the sender email
|
||||||
|
VITE_BREVO_SENDER_NAME="No Reply"
|
||||||
|
# Contact email for the contact page form to send to
|
||||||
|
VITE_CONTACT_EMAIL=""
|
||||||
|
# Logger service name to identify this website
|
||||||
|
VITE_LOGGER_SERVICE_NAME="example-site"
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
+11
-1
@@ -79,7 +79,17 @@ There are two options to create a migration file:
|
|||||||
|
|
||||||
## Applying migrations
|
## Applying migrations
|
||||||
|
|
||||||
Reset database in cloud
|
To do so without resetting the database, you can use the following command:
|
||||||
|
|
||||||
|
`npx supabase migration up`
|
||||||
|
|
||||||
|
`npx supabase migration up --linked`
|
||||||
|
|
||||||
|
> Use this command when you want to preserve existing data whilst applying new schema changes. For a fresh start with test data, use `npx supabase db reset` instead.
|
||||||
|
|
||||||
|
And for the database in the cloud:
|
||||||
|
|
||||||
|
`npx supabase db push`
|
||||||
|
|
||||||
> Prerequisite login using `npx supabase login`
|
> Prerequisite login using `npx supabase login`
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -6,6 +6,7 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"stripe": "stripe listen --forward-to localhost:5173/api/webhook/stripe",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
@@ -47,10 +48,15 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@axiomhq/winston": "^1.2.0",
|
||||||
|
"@getbrevo/brevo": "^2.2.0",
|
||||||
"@supabase/ssr": "^0.4.0",
|
"@supabase/ssr": "^0.4.0",
|
||||||
"@supabase/supabase-js": "^2.44.2",
|
"@supabase/supabase-js": "^2.44.2",
|
||||||
|
"chart.js": "^4.4.6",
|
||||||
|
"simple-datatables": "^9.2.1",
|
||||||
"stripe": "^15.4.0",
|
"stripe": "^15.4.0",
|
||||||
"ts-debounce": "^4.0.0"
|
"ts-debounce": "^4.0.0",
|
||||||
|
"winston": "^3.15.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0"
|
"node": ">=18.13.0"
|
||||||
|
|||||||
Generated
+553
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
export let cookiesAccepted;
|
export let cookiesAccepted;
|
||||||
let showBanner = false;
|
let showBanner = false;
|
||||||
@@ -7,24 +9,32 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!cookiesAccepted) {
|
if (!cookiesAccepted) {
|
||||||
const storedValue = localStorage.getItem('cookiesAccepted');
|
const storedValue = localStorage.getItem('cookiesAccepted');
|
||||||
showBanner = storedValue !== 'true';
|
showBanner = storedValue === null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const acceptCookies = () => {
|
const handleCookiesSubmit = (accepted: boolean) => {
|
||||||
localStorage.setItem('cookiesAccepted', 'true');
|
localStorage.setItem('cookiesAccepted', accepted.toString());
|
||||||
showBanner = false;
|
showBanner = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
<div class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4">
|
<div
|
||||||
|
class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
We use cookies to ensure you get the best experience on our website. By continuing, you agree
|
We use cookies to ensure you get the best experience on our website. By continuing, you agree
|
||||||
to our use of cookies.
|
to our use of cookies. For details, please see our <a href="/privacy" class="underline"
|
||||||
|
>Privacy Policy</a
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies}
|
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(true)}
|
||||||
>Accept</button
|
>Accept</button
|
||||||
>
|
>
|
||||||
|
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(false)}
|
||||||
|
>Reject</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let indicator: boolean = false;
|
||||||
|
export let posRight: boolean = false;
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
let dropdown: HTMLDivElement;
|
||||||
|
|
||||||
|
const DropdownIcon = {
|
||||||
|
closed: '▼',
|
||||||
|
open: '▲'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleClickAnywhere = ({ target }: MouseEvent) => {
|
||||||
|
if (!isOpen || !target) return;
|
||||||
|
const clickTarget = target as HTMLElement;
|
||||||
|
if (!dropdown.contains(clickTarget) || clickTarget.closest('a')) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickAnywhere);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickAnywhere);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={dropdown} class="relative block">
|
||||||
|
<div class="dropdown-title block w-full">
|
||||||
|
<button class="flex w-full" on:click={() => (isOpen = !isOpen)}>
|
||||||
|
{#if indicator}
|
||||||
|
<span class="mr-1">{@html isOpen ? DropdownIcon.open : DropdownIcon.closed}</span>
|
||||||
|
{/if}
|
||||||
|
<slot name="dropdown"></slot>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if isOpen}
|
||||||
|
<ul
|
||||||
|
class:right-0={posRight}
|
||||||
|
class="absolute rounded-box min-w-52 shadow z-[1] m-0 menu p-2 bg-base-100 text-base-content block"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { scheduleToast } from '$lib/stores/toastStore.js';
|
||||||
export let supabase: any;
|
export let supabase: any;
|
||||||
|
const SUCCESS_MESSAGE = 'Check your email for the magic link.';
|
||||||
|
|
||||||
let error = '',
|
let loading = false,
|
||||||
message = '',
|
|
||||||
loading = false,
|
|
||||||
email = '';
|
email = '';
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error = '';
|
|
||||||
message = '';
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
const { error: err } = await supabase.auth.signInWithOtp({ email });
|
const { error: err } = await supabase.auth.signInWithOtp({ email });
|
||||||
|
|
||||||
if (err) error = err.message;
|
if (err) scheduleToast(err.message, 'error', 5000);
|
||||||
else message = 'Check your email for the magic link.';
|
else scheduleToast(SUCCESS_MESSAGE, 'success', 5000);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -37,18 +35,6 @@
|
|||||||
<button class="btn btn-active btn-primary w-full" on:click disabled={loading}
|
<button class="btn btn-active btn-primary w-full" on:click disabled={loading}
|
||||||
>Send magic link</button
|
>Send magic link</button
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if message}
|
|
||||||
<div class="text-success">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="text-error">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let isMobile: boolean = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<li><a href="/products">Products</a></li>
|
|
||||||
<li class={isMobile ? '' : 'dropdown'}>
|
|
||||||
{#if isMobile}
|
|
||||||
<div tabindex="0" role="button" class="">Parent</div>
|
|
||||||
{:else}
|
|
||||||
<details>
|
|
||||||
<summary class="flex items-center"><span class="mr-1">▼</span>Parent </summary>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
<ul
|
|
||||||
class="{isMobile
|
|
||||||
? ''
|
|
||||||
: 'dropdown-content rounded-box w-52 shadow'} z-[1] menu p-2 bg-base-100 text-base-content"
|
|
||||||
>
|
|
||||||
<li><a href="/">Submenu 1</a></li>
|
|
||||||
<li><a href="/">Submenu 2</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><a href="/tools/exampleproduct">Example Product</a></li>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function emitSignedInEvent() {
|
|
||||||
dispatch('signedIn', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
async function signInWithEmail() {
|
|
||||||
// TODO use the data and error from the response
|
|
||||||
const { data, error } = await supabase.auth
|
|
||||||
.signInWithPassword({
|
|
||||||
email: email,
|
|
||||||
password: password
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
emitSignedInEvent();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forgotPassword() {
|
|
||||||
if (email === '') {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Please enter your email address',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: 'https://example.com/auth/update-password'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error sending password reset email:', error.message);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Error sending password reset email. Please try again.',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('Password reset email sent:', data);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Password reset email sent. Please check your inbox.',
|
|
||||||
toastType: 'success'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
|
||||||
<form class="card-body">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
|
||||||
/><path
|
|
||||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<button id="forgot-password" class="label-text-alt link link-hover" on:click={forgotPassword}>
|
|
||||||
Forgot your password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mt-2">
|
|
||||||
<button class="btn btn-primary" on:click={signInWithEmail}>Sign In</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
function emitSignedOutEvent() {
|
function emitSignedOutEvent() {
|
||||||
@@ -7,14 +10,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
|
let localBasket: Basket;
|
||||||
|
const unsubscribe = basket.subscribe((value) => {
|
||||||
|
localBasket = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// TODO use the error from the response
|
const { error } = await supabase.auth.signOut();
|
||||||
const { error } = await supabase.auth.signOut().then(() => {
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error signing out:', error.message);
|
||||||
|
alert('Error signing out');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
emitSignedOutEvent();
|
emitSignedOutEvent();
|
||||||
});
|
|
||||||
|
// Clear the basket
|
||||||
|
localBasket = { items: [], subtotal: 0 };
|
||||||
|
basket.set(localBasket);
|
||||||
|
unsubscribe();
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem('basket');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error clearing basket:', err);
|
||||||
|
alert('Error clearing basket');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={signOut}>Sign Out</button>
|
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
async function signUpNewUser() {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.auth.signUp({
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
options: {
|
|
||||||
// Redirect URL after successful sign-up
|
|
||||||
redirectTo: '/account'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle success (optional)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Sign up error:', error.message);
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="card-body">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
|
||||||
/><path
|
|
||||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mt-6">
|
|
||||||
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -11,10 +11,11 @@
|
|||||||
export let price = 0;
|
export let price = 0;
|
||||||
export let quantity = 0;
|
export let quantity = 0;
|
||||||
|
|
||||||
|
$: dispatch('changeQuantity', { itemId, quantity });
|
||||||
|
|
||||||
function reduceQuantityByOne() {
|
function reduceQuantityByOne() {
|
||||||
if (quantity > 1) {
|
if (quantity > 1) {
|
||||||
quantity--;
|
quantity--;
|
||||||
dispatch('changeQuantity', { itemId, quantity });
|
|
||||||
} else {
|
} else {
|
||||||
dispatch('removeItem', { itemId });
|
dispatch('removeItem', { itemId });
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
|
|
||||||
function increaseQuantityByOne() {
|
function increaseQuantityByOne() {
|
||||||
quantity++;
|
quantity++;
|
||||||
dispatch('changeQuantity', { itemId, quantity });
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Session, SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import MagicLink from '../MagicLink.svelte';
|
||||||
|
import NavLinks from './NavLinks.svelte';
|
||||||
|
import SignOut from '../SignOut.svelte';
|
||||||
|
import Dropdown from '../Dropdown.svelte';
|
||||||
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { Database } from '$lib/types/supabase';
|
||||||
|
|
||||||
|
export let session: Session | null;
|
||||||
|
export let supabase: SupabaseClient<Database>;
|
||||||
|
|
||||||
|
let localBasket: Basket;
|
||||||
|
const unsubscribeToBasket = basket.subscribe((value) => {
|
||||||
|
localBasket = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeToBasket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="bg-primary text-primary-content p-4">
|
||||||
|
<!-- Nav Container -->
|
||||||
|
<div class="container mx-auto flex justify-between items-center">
|
||||||
|
<!-- Hamburger Menu Button (Mobile) -->
|
||||||
|
<div class="lg:hidden mr-4">
|
||||||
|
<Dropdown>
|
||||||
|
<div slot="dropdown">
|
||||||
|
<button class="block text-white focus:outline-none" aria-label="Open navigation menu">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h8m-8 6h16"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Dropdown content -->
|
||||||
|
<NavLinks isMobile {session} {supabase} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<NavLinks {session} />
|
||||||
|
</div>
|
||||||
|
<!-- Right Side: Basket & User Account -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Basket/Checkout Icon -->
|
||||||
|
<a href="/checkout" class="btn btn-ghost btn-circle" aria-label="View basket">
|
||||||
|
<div class="indicator">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if localBasket.items.length > 0}
|
||||||
|
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<!-- User Account Dropdown -->
|
||||||
|
<Dropdown posRight>
|
||||||
|
<div slot="dropdown">
|
||||||
|
<div
|
||||||
|
class="btn btn-ghost btn-circle avatar placeholder hidden md:flex"
|
||||||
|
aria-label="User account menu"
|
||||||
|
>
|
||||||
|
<div class="w-10 rounded-full">
|
||||||
|
{#if session?.user}
|
||||||
|
<div class="user-circle text-primary-content border-primary-content">
|
||||||
|
<span class="text-xl"
|
||||||
|
>{session?.user.email ? session?.user.email[0].toUpperCase() : '?'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Dropdown content -->
|
||||||
|
{#if session?.user}
|
||||||
|
<li><a href="/account">Account</a></li>
|
||||||
|
<li><SignOut {supabase} /></li>
|
||||||
|
{:else}
|
||||||
|
<MagicLink {supabase} />
|
||||||
|
{/if}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import SignOut from '../SignOut.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let linkItem: NavLinkItem;
|
||||||
|
export let supabase: SupabaseClient | null = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !linkItem.isSignout}
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href={linkItem.href}
|
||||||
|
aria-current={linkItem.href === $page.url.pathname ? 'page' : undefined}>{linkItem.text}</a
|
||||||
|
>
|
||||||
|
{:else if supabase && linkItem.isSignout}
|
||||||
|
<SignOut {supabase} />
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import Dropdown from '../Dropdown.svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import NavLink from './NavLink.svelte';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export let linkItem: NavLinkItem;
|
||||||
|
export let isMobile: boolean;
|
||||||
|
export let supabase: SupabaseClient | null;
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isMobile}
|
||||||
|
<Dropdown indicator>
|
||||||
|
<div slot="dropdown">{linkItem.text}</div>
|
||||||
|
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li><NavLink linkItem={child} /></li>
|
||||||
|
{/each}
|
||||||
|
</Dropdown>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class:menu-dropdown-show={isOpen}
|
||||||
|
class="menu-dropdown-toggle"
|
||||||
|
on:click={() => (isOpen = !isOpen)}
|
||||||
|
>
|
||||||
|
{linkItem.text}
|
||||||
|
</button>
|
||||||
|
{#if isOpen}
|
||||||
|
<ul
|
||||||
|
role="menu"
|
||||||
|
class="menu-dropdown menu-dropdown-show"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li><NavLink linkItem={child} {supabase} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import type { Session } from '@supabase/supabase-js';
|
||||||
|
import NavLinkParent from './NavLinkParent.svelte';
|
||||||
|
import { type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import NavLink from './NavLink.svelte';
|
||||||
|
|
||||||
|
export let session: Session | null;
|
||||||
|
export let supabase: SupabaseClient | null = null;
|
||||||
|
export let isMobile = false;
|
||||||
|
|
||||||
|
const userAccountLinks: NavLinkItem[] = [];
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
if (session) {
|
||||||
|
userAccountLinks.push({
|
||||||
|
text: 'User', // TODO - #62 replace with user's name - will require a database call to get the user's name - suggest setting up a user model to store user data.
|
||||||
|
isParent: true,
|
||||||
|
isUserAccount: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'Account',
|
||||||
|
href: '/account'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Sign Out',
|
||||||
|
isSignout: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
userAccountLinks.push({
|
||||||
|
text: 'Log in',
|
||||||
|
href: '/login'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const links: NavLinkItem[] = [
|
||||||
|
...userAccountLinks,
|
||||||
|
{
|
||||||
|
text: 'Products',
|
||||||
|
href: '/products'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Parent',
|
||||||
|
isParent: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'Submenu 1',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Submenu 2',
|
||||||
|
href: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Example Product',
|
||||||
|
href: '/tools/exampleproduct'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul role="menu" class:flex={!isMobile} class:space-x-4={!isMobile}>
|
||||||
|
{#each links as link (link.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li class:md:hidden={link.isUserAccount}>
|
||||||
|
{#if link.isParent}
|
||||||
|
<NavLinkParent linkItem={link} {isMobile} {supabase} />
|
||||||
|
{:else}
|
||||||
|
<NavLink linkItem={link} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { toast } from '$lib/stores/toastStore.js';
|
||||||
|
import type { Toast } from '$lib/types/Shared/Toast';
|
||||||
|
|
||||||
|
let localToast: Toast;
|
||||||
|
const unsubscribeToToastStore = toast.subscribe((value: Toast) => {
|
||||||
|
localToast = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeToToastStore();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !localToast.hideToast}
|
||||||
|
<div
|
||||||
|
id={localToast.toastType == 'success' ? 'success-toast' : 'error-toast'}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
class="alert {localToast.toastType == 'success'
|
||||||
|
? 'alert-success'
|
||||||
|
: 'alert-error'} fixed top-20 right-3 max-w-80 z-50 text-white"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
{#if localToast.toastType == 'success'}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
><g
|
||||||
|
><g id="Error_1_"
|
||||||
|
><g id="Error">
|
||||||
|
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
|
||||||
|
<path
|
||||||
|
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
|
||||||
|
id="Exclamatory_x5F_Sign"
|
||||||
|
style="fill:#D72828;"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g></g
|
||||||
|
></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span>{localToast.toastMessage}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Toast } from '$lib/types/Shared/Toast';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const MIN_DELAY = 3000;
|
||||||
|
|
||||||
|
export const toast = writable<Toast>({ hideToast: true, toastMessage: '', toastType: 'success' });
|
||||||
|
export function scheduleToast(message: string, toastType: 'success' | 'error', delay: number = MIN_DELAY) {
|
||||||
|
|
||||||
|
toast.update((value) => {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
hideToast: false,
|
||||||
|
toastMessage: message,
|
||||||
|
toastType: toastType
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.update((value) => {
|
||||||
|
return { ...value, hideToast: true, toastMessage: '' };
|
||||||
|
});
|
||||||
|
}, Math.max(delay, MIN_DELAY));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface NavLinkItem {
|
||||||
|
text: string;
|
||||||
|
href?: string;
|
||||||
|
isParent?: boolean;
|
||||||
|
children?: NavLinkItem[];
|
||||||
|
isSignout?: boolean;
|
||||||
|
isUserAccount?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type Toast = {
|
||||||
|
hideToast: boolean,
|
||||||
|
toastMessage: string,
|
||||||
|
toastType: 'success' | 'error'
|
||||||
|
}
|
||||||
Vendored
+26
@@ -34,6 +34,32 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
|
account_deletion_requests: {
|
||||||
|
Row: {
|
||||||
|
requested_at: string
|
||||||
|
token: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
requested_at?: string
|
||||||
|
token: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
requested_at?: string
|
||||||
|
token?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "account_deletion_requests_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
credit_transactions: {
|
credit_transactions: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import brevo from '@getbrevo/brevo';
|
||||||
|
import {
|
||||||
|
VITE_BREVO_API_KEY,
|
||||||
|
VITE_BREVO_SENDER_EMAIL,
|
||||||
|
VITE_BREVO_SENDER_NAME
|
||||||
|
} from '$env/static/private';
|
||||||
|
|
||||||
|
const apiInstance = new brevo.TransactionalEmailsApi();
|
||||||
|
const apiKey = apiInstance.authentications['apiKey'];
|
||||||
|
if (!VITE_BREVO_API_KEY) {
|
||||||
|
throw new Error('Brevo API key is not configured');
|
||||||
|
}
|
||||||
|
apiKey.apiKey = VITE_BREVO_API_KEY;
|
||||||
|
|
||||||
|
const sendEmail = async (subject: string, htmlContentString: string, toAddress: string) => {
|
||||||
|
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||||
|
|
||||||
|
sendSmtpEmail.subject = subject;
|
||||||
|
sendSmtpEmail.htmlContent = htmlContentString;
|
||||||
|
sendSmtpEmail.sender = { name: VITE_BREVO_SENDER_NAME, email: VITE_BREVO_SENDER_EMAIL };
|
||||||
|
sendSmtpEmail.to = [{ email: toAddress, name: toAddress }];
|
||||||
|
sendSmtpEmail.replyTo = { email: VITE_BREVO_SENDER_EMAIL, name: VITE_BREVO_SENDER_NAME };
|
||||||
|
|
||||||
|
apiInstance.sendTransacEmail(sendSmtpEmail).then(
|
||||||
|
function (data) {
|
||||||
|
console.log('API called successfully. Returned data: ' + JSON.stringify(data));
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { sendEmail };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createLogger, transports, format } from 'winston';
|
||||||
|
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
|
||||||
|
import {
|
||||||
|
VITE_AXIOM_DATASET,
|
||||||
|
VITE_AXIOM_TOKEN,
|
||||||
|
VITE_LOGGER_SERVICE_NAME
|
||||||
|
} from '$env/static/private';
|
||||||
|
|
||||||
|
const axiomTransport = new AxiomTransport({
|
||||||
|
dataset: VITE_AXIOM_DATASET,
|
||||||
|
token: VITE_AXIOM_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: 'info',
|
||||||
|
defaultMeta: { service: VITE_LOGGER_SERVICE_NAME }, // Change this so you can identify your website if you have multiple logging into Axiom
|
||||||
|
format: format.combine(
|
||||||
|
format.errors({ stack: true }), // Captures error stack traces
|
||||||
|
format.timestamp(),
|
||||||
|
format.json() // Formats logs as JSON for structured logging
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new transports.Console({
|
||||||
|
format: format.combine(
|
||||||
|
format.colorize(),
|
||||||
|
format.printf(({ timestamp, level, message, stack }) => {
|
||||||
|
// Stack traces will be included in error messages automatically
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}${stack ? `\n${stack}` : ''}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
axiomTransport
|
||||||
|
],
|
||||||
|
exceptionHandlers: [axiomTransport], // Logs uncaught exceptions
|
||||||
|
rejectionHandlers: [axiomTransport] // Logs unhandled promise rejections
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncomment this for testing your initial logging setup
|
||||||
|
// logger.log({
|
||||||
|
// level: 'info',
|
||||||
|
// message: 'Logger successfully setup'
|
||||||
|
// });
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
type RateLimitStore = {
|
||||||
|
[key: string]: { count: number; lastRequest: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitStore: RateLimitStore = {};
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 5000; // 5 minutes
|
||||||
|
const MAX_REQUESTS = 5;
|
||||||
|
|
||||||
|
export function isRateLimited(ip: string): boolean {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const requestInfo = rateLimitStore[ip];
|
||||||
|
|
||||||
|
if (!requestInfo) {
|
||||||
|
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTime - requestInfo.lastRequest > RATE_LIMIT_WINDOW) {
|
||||||
|
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestInfo.count >= MAX_REQUESTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitStore[ip].count += 1;
|
||||||
|
rateLimitStore[ip].lastRequest = currentTime;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+231
-36
@@ -4,6 +4,8 @@ import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
|
|||||||
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||||
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
|
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
|
||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { sendEmail } from '../brevo/email';
|
||||||
|
|
||||||
const toDateTime = (secs: number) => {
|
const toDateTime = (secs: number) => {
|
||||||
const t = new Date(+0); // Unix epoch start.
|
const t = new Date(+0); // Unix epoch start.
|
||||||
@@ -39,7 +41,7 @@ const upsertProductRecord = async (product: stripe.Product) => {
|
|||||||
|
|
||||||
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
|
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
|
||||||
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
|
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
|
||||||
console.log(`Product inserted/updated: ${product.id}`);
|
logger.info(`Product inserted/updated: ${product.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
||||||
@@ -61,7 +63,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
|||||||
|
|
||||||
if (upsertError?.message.includes('foreign key constraint')) {
|
if (upsertError?.message.includes('foreign key constraint')) {
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
logger.info(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
||||||
} else {
|
} else {
|
||||||
@@ -72,7 +74,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
|||||||
} else if (upsertError) {
|
} else if (upsertError) {
|
||||||
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Price inserted/updated: ${price.id}`);
|
logger.info(`Price inserted/updated: ${price.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,13 +84,13 @@ const deleteProductRecord = async (product: stripe.Product) => {
|
|||||||
.delete()
|
.delete()
|
||||||
.eq('id', product.id);
|
.eq('id', product.id);
|
||||||
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
||||||
console.log(`Product deleted: ${product.id}`);
|
logger.info(`Product deleted: ${product.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePriceRecord = async (price: stripe.Price) => {
|
const deletePriceRecord = async (price: stripe.Price) => {
|
||||||
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
|
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
|
||||||
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
|
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
|
||||||
console.log(`Price deleted: ${price.id}`);
|
logger.info(`Price deleted: ${price.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
||||||
@@ -254,11 +256,18 @@ const manageSubscriptionStatusChange = async (
|
|||||||
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
|
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error: upsertError } = await supabaseAdmin
|
try {
|
||||||
.from('subscriptions')
|
const { error: upsertError } = await supabaseAdmin
|
||||||
.upsert([subscriptionData]);
|
.from('subscriptions')
|
||||||
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
.upsert([subscriptionData]);
|
||||||
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
if (upsertError) {
|
||||||
|
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
||||||
|
|
||||||
// // For a new subscription copy the billing details to the customer object.
|
// // For a new subscription copy the billing details to the customer object.
|
||||||
// // NOTE: This is a costly operation and should happen at the very end.
|
// // NOTE: This is a costly operation and should happen at the very end.
|
||||||
@@ -271,7 +280,7 @@ const manageSubscriptionStatusChange = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
||||||
console.log(
|
logger.info(
|
||||||
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -284,40 +293,48 @@ const recordProductPurchase = async (userId: string, productId: string, priceId:
|
|||||||
throw new Error(`Failed to record product purchase: ${error.message}`);
|
throw new Error(`Failed to record product purchase: ${error.message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
logger.info(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasProductAccess = async (userId: string, productId: string) => {
|
const hasProductAccess = async (userId: string, productId: string) => {
|
||||||
// Check if user has purchased the product
|
// Check if user has purchased the product
|
||||||
const { data: purchases, error: purchaseError } = await supabaseAdmin
|
try {
|
||||||
.from('purchases')
|
const { data: purchases, error: purchaseError } = await supabaseAdmin
|
||||||
.select('*')
|
.from('purchases')
|
||||||
.eq('user_id', userId)
|
.select('*')
|
||||||
.eq('product_id', productId);
|
.eq('user_id', userId)
|
||||||
|
.eq('product_id', productId);
|
||||||
|
|
||||||
if (purchaseError) throw new Error(purchaseError.message);
|
if (purchaseError) throw new Error(purchaseError.message);
|
||||||
|
|
||||||
if (purchases && purchases.length > 0) {
|
if (purchases && purchases.length > 0) {
|
||||||
// User has purchased the product
|
// User has purchased the product
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has an active subscription for the product
|
// Check if user has an active subscription for the product
|
||||||
const { data: subscriptions, error: subError } = await supabaseAdmin
|
try {
|
||||||
.from('subscriptions')
|
const { data: subscriptions, error: subError } = await supabaseAdmin
|
||||||
.select('*')
|
.from('subscriptions')
|
||||||
.eq('user_id', userId)
|
.select('*')
|
||||||
.eq('product_id', productId)
|
.eq('user_id', userId)
|
||||||
.in('status', ['active', 'trialing']);
|
.eq('product_id', productId)
|
||||||
|
.in('status', ['active', 'trialing']);
|
||||||
|
|
||||||
if (subError) throw new Error(subError.message);
|
if (subError) throw new Error(subError.message);
|
||||||
|
|
||||||
if (subscriptions && subscriptions.length > 0) {
|
if (subscriptions && subscriptions.length > 0) {
|
||||||
// User has an active subscription
|
// User has an active subscription
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -381,7 +398,7 @@ const updateUserCredits = async (userId: string, creditsChange: number, descript
|
|||||||
|
|
||||||
await logCreditTransaction(userId, creditsChange, description);
|
await logCreditTransaction(userId, creditsChange, description);
|
||||||
|
|
||||||
console.log(
|
logger.info(
|
||||||
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -390,7 +407,7 @@ const addCredits = async (userId: string, creditsToAdd: number, description: str
|
|||||||
try {
|
try {
|
||||||
await updateUserCredits(userId, creditsToAdd, description);
|
await updateUserCredits(userId, creditsToAdd, description);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,7 +415,7 @@ const deductCredits = async (userId: string, creditsToDeduct: number, descriptio
|
|||||||
try {
|
try {
|
||||||
await updateUserCredits(userId, -creditsToDeduct, description);
|
await updateUserCredits(userId, -creditsToDeduct, description);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -434,6 +451,28 @@ const getUserCredits = async (userId: string) => {
|
|||||||
return creditData.credits_remaining;
|
return creditData.credits_remaining;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the credit transaction history for a specific user.
|
||||||
|
*
|
||||||
|
* @param userId - The ID of the user whose transactions are to be fetched.
|
||||||
|
* @returns Array of transaction records containing credits_change, description, and created_at.
|
||||||
|
*/
|
||||||
|
export const getUserCreditTransactions = async (userId: string) => {
|
||||||
|
if (!userId) throw new Error('User ID is required');
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('credit_transactions')
|
||||||
|
.select('credits_change, description, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false }); // Orders transactions with the latest ones first
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Error fetching transactions: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || [];
|
||||||
|
};
|
||||||
|
|
||||||
type ProductWithPrices = Product & {
|
type ProductWithPrices = Product & {
|
||||||
prices: Price[];
|
prices: Price[];
|
||||||
actualPrice?: number;
|
actualPrice?: number;
|
||||||
@@ -480,6 +519,158 @@ const getProductById = async (productId: string) => {
|
|||||||
return product as Product;
|
return product as Product;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestAccountDeletion = async (userId: string, userEmail: string, baseUrl: string) => {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
|
||||||
|
const { error: insertError } = await supabaseAdmin.from('account_deletion_requests').upsert({
|
||||||
|
user_id: userId,
|
||||||
|
token,
|
||||||
|
requested_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) throw new Error(`Account deletion request failed: ${insertError.message}`);
|
||||||
|
|
||||||
|
const deletionLink = `${baseUrl}/api/delete-account?token=${token}`;
|
||||||
|
|
||||||
|
// Send deletion email using Brevo
|
||||||
|
try {
|
||||||
|
await sendEmail(
|
||||||
|
'Confirm Account Deletion',
|
||||||
|
`<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`,
|
||||||
|
userEmail
|
||||||
|
);
|
||||||
|
console.log('Deletion email sent successfully.');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send deletion email:', emailError);
|
||||||
|
throw new Error('Failed to send confirmation email.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async (token: string) => {
|
||||||
|
// Verify the token
|
||||||
|
const { data: deletionRequest, error } = await supabaseAdmin
|
||||||
|
.from('account_deletion_requests')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('token', token)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !deletionRequest) {
|
||||||
|
return new Response('Invalid or expired token', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// N.B. The SQL here will either cascade delete or nullify the foreign key constraints depending on the table, to enable anonymisation
|
||||||
|
const { error: deletionError } = await supabaseAdmin.auth.admin.deleteUser(
|
||||||
|
deletionRequest.user_id
|
||||||
|
);
|
||||||
|
if (deletionError) {
|
||||||
|
throw new Error(`Failed to delete user: ${deletionError.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserSubscriptions = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
// Step 1: Retrieve subscription data
|
||||||
|
const { data: subscriptions, error } = await supabaseAdmin
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('Error retrieving subscriptions:', error.message);
|
||||||
|
throw new Error('Failed to fetch subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Map through each subscription and fetch product and price details
|
||||||
|
const subscriptionDetails = await Promise.all(
|
||||||
|
subscriptions.map(async (sub) => {
|
||||||
|
// Fetch product and price details from Stripe
|
||||||
|
const product = await stripeClient.products.retrieve(sub.product_id);
|
||||||
|
const price = await stripeClient.prices.retrieve(sub.price_id);
|
||||||
|
|
||||||
|
const expiryDate = new Date(sub.current_period_end);
|
||||||
|
|
||||||
|
// Format output data
|
||||||
|
return {
|
||||||
|
productName: product.name,
|
||||||
|
amount: (price.unit_amount / 100).toFixed(2),
|
||||||
|
currency: price.currency.toUpperCase(),
|
||||||
|
interval: price.recurring?.interval || 'one-time',
|
||||||
|
expiryDate: expiryDate.toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
status: sub.status
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return subscriptionDetails;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('An error occurred while retrieving subscription details:', error);
|
||||||
|
throw new Error('Could not retrieve subscription details');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTransactions = async (
|
||||||
|
userId: string,
|
||||||
|
perPage: number,
|
||||||
|
startingAfter: string | undefined = undefined,
|
||||||
|
endingBefore: string | undefined = undefined
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// Step 1: Retrieve the customer ID associated with the user
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('customers')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('Error retrieving user data:', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = data?.stripe_customer_id;
|
||||||
|
if (!customerId) {
|
||||||
|
throw new Error('No Stripe customer ID found for this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch charge transactions from Stripe
|
||||||
|
const params: stripe.ChargeListParams = {
|
||||||
|
customer: customerId,
|
||||||
|
limit: perPage
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startingAfter) params.starting_after = startingAfter;
|
||||||
|
if (endingBefore) params.ending_before = endingBefore;
|
||||||
|
|
||||||
|
const charges = await stripeClient.charges.list(params);
|
||||||
|
|
||||||
|
const hasNextPage = charges.has_more;
|
||||||
|
|
||||||
|
// Step 3: Format transaction data
|
||||||
|
const transactions = charges.data.map((charge) => ({
|
||||||
|
id: charge.id,
|
||||||
|
amount: (charge.amount / 100).toFixed(2),
|
||||||
|
currency: charge.currency.toUpperCase(),
|
||||||
|
description: charge.description ?? 'No description provided',
|
||||||
|
status: charge.status,
|
||||||
|
created: new Date(charge.created * 1000).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
receipt_url: charge.receipt_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { transactions, hasNextPage };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('An error occurred while retrieving transactions:', error);
|
||||||
|
throw new Error('Could not retrieve transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
upsertProductRecord,
|
upsertProductRecord,
|
||||||
upsertPriceRecord,
|
upsertPriceRecord,
|
||||||
@@ -495,5 +686,9 @@ export {
|
|||||||
getActiveProductsWithPrices,
|
getActiveProductsWithPrices,
|
||||||
getProductById,
|
getProductById,
|
||||||
upsertCustomerToSupabase,
|
upsertCustomerToSupabase,
|
||||||
getStripeCustomerId
|
getStripeCustomerId,
|
||||||
|
getUserSubscriptions,
|
||||||
|
getUserTransactions,
|
||||||
|
requestAccountDeletion,
|
||||||
|
deleteAccount
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export async function signOut(supabase: SupabaseClient, callback: () => void): Promise<void> {
|
||||||
|
// TODO - #61 use the error from the response
|
||||||
|
const { error } = await supabase.auth.signOut().then(callback);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-4">Cookie Policy</h1>
|
||||||
|
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This Cookie Policy explains how [Your Company/Website Name] ("we", "us", or "our") uses
|
||||||
|
cookies and similar technologies to collect and store information when you visit our website.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">2. What Are Cookies?</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Cookies are small text files stored on your device (computer, tablet, smartphone) by your web
|
||||||
|
browser when you visit a website. They help us remember your preferences and improve your
|
||||||
|
experience on our website.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">3. Types of Cookies We Use</h2>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Essential Cookies</span>: These cookies are necessary for the
|
||||||
|
website to function properly and cannot be disabled in our systems. They include login
|
||||||
|
authentication and session management cookies.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Performance Cookies</span>: These cookies help us understand how
|
||||||
|
visitors interact with our website by collecting information on usage patterns, such as
|
||||||
|
pages visited and links clicked.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Functional Cookies</span>: These cookies allow us to remember
|
||||||
|
choices you make (such as your language preference) to provide a more personalised
|
||||||
|
experience.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Targeting/Advertising Cookies</span>: These cookies are used to
|
||||||
|
deliver relevant advertisements and track the effectiveness of our marketing campaigns.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">4. How We Use Cookies</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We use cookies to improve the functionality and performance of our website, analyse visitor
|
||||||
|
usage, and provide personalised content and advertisements. This helps us improve our website
|
||||||
|
and better understand our audience.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">5. Managing Your Cookie Preferences</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
You have the right to accept or reject cookies. You can manage your cookie preferences by
|
||||||
|
adjusting your browser settings. However, disabling certain cookies may affect your experience
|
||||||
|
on our website. For specific instructions on managing cookies, please consult your browser's
|
||||||
|
help documentation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">6. Changes to Our Cookie Policy</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We may update this Cookie Policy from time to time. Significant changes will be posted on this
|
||||||
|
page. We encourage you to review this policy periodically to stay informed about our use of
|
||||||
|
cookies.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For questions about our Cookie Policy, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>
|
||||||
|
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-4">Privacy Policy</h1>
|
||||||
|
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This Privacy Policy explains how we at [Your Company/Website Name] collect, use, and protect
|
||||||
|
your personal information when you visit our website. We are committed to safeguarding your
|
||||||
|
data and complying with relevant data protection laws, including the General Data Protection
|
||||||
|
Regulation (GDPR).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">2. Information We Collect</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We collect personal and non-personal information to provide our services and enhance your
|
||||||
|
experience.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Personal Information</span>: Details like your name, email
|
||||||
|
address, and payment information collected when you make a purchase or create an account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Usage Data</span>: Information such as IP address, browser type,
|
||||||
|
and browsing history for analytics and functionality.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">3. How We Use Your Information</h2>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>To process orders and manage customer support.</li>
|
||||||
|
<li>To improve website functionality and provide a better user experience.</li>
|
||||||
|
<li>To send marketing emails, if you opt in (you may unsubscribe at any time).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">4. Sharing Information with Third Parties</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We work with trusted third-party providers to offer secure services, such as payment
|
||||||
|
processing and authentication.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-xl font-semibold">Stripe</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For payment processing, we use <a
|
||||||
|
href="https://stripe.com"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary font-semibold">Stripe</a
|
||||||
|
>. Please refer to their
|
||||||
|
<a href="https://stripe.com/privacy" target="_blank" class="text-primary font-semibold"
|
||||||
|
>Privacy Policy</a
|
||||||
|
> for details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-xl font-semibold">Supabase</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For database and authentication, we use <a
|
||||||
|
href="https://supabase.com"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary font-semibold">Supabase</a
|
||||||
|
>. See their
|
||||||
|
<a href="https://supabase.com/privacy" target="_blank" class="text-primary font-semibold"
|
||||||
|
>Privacy Policy</a
|
||||||
|
> for more information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">5. Cookies and Tracking</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We use cookies to provide essential site functionality, analytics, and improved user
|
||||||
|
experience. By continuing to use our site, you consent to our use of cookies. You can manage
|
||||||
|
your cookie preferences in your browser settings. For more details, please refer to our <a
|
||||||
|
href="/cookies"
|
||||||
|
class="text-primary font-semibold">Cookie Policy</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">6. Your Data Rights</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Under GDPR, you have the right to access, correct, delete, and restrict the processing of your
|
||||||
|
personal data. To make a request, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">7. Data Security</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We take reasonable steps to protect your information through encryption and secure access
|
||||||
|
protocols. However, no online service can be 100% secure.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">8. Policy Updates</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We may update this policy periodically. Significant changes will be posted on this page, so
|
||||||
|
please review it regularly.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
If you have questions about this Privacy Policy, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>
|
||||||
|
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin';
|
||||||
|
import { requestAccountDeletion } from '$lib/utils/supabase/admin';
|
||||||
|
|
||||||
|
const transactionsPerPage = 5;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: user } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select(`name`)
|
||||||
|
.eq('id', session.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const subscriptions = await getUserSubscriptions(session.user.id);
|
||||||
|
|
||||||
|
const transactionsData = await getUserTransactions(session.user.id, transactionsPerPage);
|
||||||
|
|
||||||
|
const transactions = transactionsData?.transactions ?? [];
|
||||||
|
const firstTransactionId = transactions.length > 0 ? transactions[0].id : undefined;
|
||||||
|
const hasMoreThanOnePage = transactionsData?.hasNextPage ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
subscriptions,
|
||||||
|
transactions,
|
||||||
|
firstTransactionId,
|
||||||
|
hasMoreThanOnePage,
|
||||||
|
pageSize: transactionsPerPage
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
delete: async ({ url, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
const baseUrl = url.origin;
|
||||||
|
if (session) {
|
||||||
|
if (!session.user.email) {
|
||||||
|
return fail(400, { message: 'Email is required for account deletion' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestAccountDeletion(session.user.id, session.user.email, baseUrl);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Account deletion request submitted successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to request account deletion:', error);
|
||||||
|
return fail(500, { message: 'Failed to process deletion request' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return fail(401, { name });
|
||||||
|
}
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
.eq('id', session?.user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('Failed to update user name', {
|
||||||
|
userId: session?.user.id,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return fail(500, {
|
||||||
|
name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
},
|
||||||
|
paginate: async ({ request, locals: { safeGetSession } }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const pageSize = transactionsPerPage;
|
||||||
|
|
||||||
|
let startingAfter = formData.get('startingAfter')?.toString() || undefined;
|
||||||
|
if (startingAfter === 'undefined') {
|
||||||
|
startingAfter = undefined;
|
||||||
|
}
|
||||||
|
let endingBefore = formData.get('endingBefore')?.toString() || undefined;
|
||||||
|
if (endingBefore === 'undefined') {
|
||||||
|
endingBefore = undefined;
|
||||||
|
}
|
||||||
|
const firstTransactionId = formData.get('firstTransactionId')?.toString();
|
||||||
|
const hasMoreThanOnePage = formData.get('hasMoreThanOnePage') === 'true';
|
||||||
|
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
if (!session) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { transactions, hasNextPage } = await getUserTransactions(
|
||||||
|
session.user.id,
|
||||||
|
pageSize,
|
||||||
|
startingAfter,
|
||||||
|
endingBefore
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirstPage = transactions.length > 0 && transactions[0].id === firstTransactionId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions: JSON.stringify(transactions),
|
||||||
|
hasNextPage: hasNextPage || (isFirstPage && hasMoreThanOnePage),
|
||||||
|
isFirstPage
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch transactions', { userId: session.user.id, error });
|
||||||
|
return fail(500, { error: 'Failed to load transactions' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
export let form;
|
||||||
|
|
||||||
|
let { session, user, subscriptions, transactions, firstTransactionId, hasMoreThanOnePage } = data;
|
||||||
|
$: ({ session, user, transactions, firstTransactionId, hasMoreThanOnePage } = data);
|
||||||
|
|
||||||
|
let profileForm: HTMLFormElement;
|
||||||
|
let loading = false;
|
||||||
|
let name: string = user?.name ?? '';
|
||||||
|
|
||||||
|
const handleSubmit: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
return async () => {
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
loading = false;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountDeletion: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
if (session.user && session.user.email) {
|
||||||
|
alert('Check your email to confirm account deletion.');
|
||||||
|
} else {
|
||||||
|
alert('Please sign in to delete your account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let startingAfter: string | undefined = undefined;
|
||||||
|
let endingBefore: string | undefined = undefined;
|
||||||
|
let hasNextPage = hasMoreThanOnePage;
|
||||||
|
let hasPreviousPage = false;
|
||||||
|
|
||||||
|
const handlePaginationSubmit: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
return async ({ result }) => {
|
||||||
|
transactions = JSON.parse(result.data.transactions);
|
||||||
|
hasNextPage = result.data.hasNextPage;
|
||||||
|
hasPreviousPage = !result.data.isFirstPage;
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="account-page max-w-4xl mx-auto p-6 bg-base-100 rounded-lg shadow-md space-y-6 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- Flex container for two columns -->
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
|
<!-- Left Column: Account Overview, Subscriptions, and Billing History -->
|
||||||
|
<div class="flex-1 space-y-6">
|
||||||
|
<!-- Account Overview -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Account Overview</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="email" class="label font-medium">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
class="input input-bordered"
|
||||||
|
value={session.user.email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">Signed in with magic link.</div>
|
||||||
|
<form
|
||||||
|
class="form-widget space-y-4"
|
||||||
|
method="post"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={handleSubmit}
|
||||||
|
bind:this={profileForm}
|
||||||
|
>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="name" class="label font-medium">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={form?.name ?? name}
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
value={loading ? 'Loading...' : 'Update Name'}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Active Subscriptions -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Subscriptions</h2>
|
||||||
|
{#if subscriptions.length === 0}
|
||||||
|
<p class="text-gray-600">You do not have any subscriptions.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each subscriptions as subscription}
|
||||||
|
<li class="p-4 border border-gray-200 rounded-md">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{subscription.productName}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Price: {subscription.amount}
|
||||||
|
{subscription.currency} / {subscription.interval}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Expiry Date: {subscription.expiryDate}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Status: {subscription.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="btn btn-secondary btn-sm">Manage</button> -->
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Credit Usage</h2>
|
||||||
|
<p class="text-gray-600">Monitor your credit balance and view transaction history.</p>
|
||||||
|
<a href="/credits" class="btn btn-secondary w-full">View details</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Billing History -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Billing History</h2>
|
||||||
|
|
||||||
|
{#if transactions && transactions.length > 0}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each transactions as transaction}
|
||||||
|
<li class="flex justify-between p-4 border border-gray-200 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Date: {transaction.created}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Amount: {transaction.amount}
|
||||||
|
{transaction.currency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if transaction.receipt_url}
|
||||||
|
<a href={transaction.receipt_url} class="btn btn-outline btn-sm" target="_blank">
|
||||||
|
Download Receipt
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-500">Receipt unavailable</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-600">No transactions found.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Pagination Form -->
|
||||||
|
<form method="post" action="?/paginate" use:enhance={handlePaginationSubmit}>
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center space-x-2 mt-4"
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Transactions pagination"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="startingAfter" value={startingAfter} />
|
||||||
|
<input type="hidden" name="endingBefore" value={endingBefore} />
|
||||||
|
<input type="hidden" name="firstTransactionId" value={firstTransactionId} />
|
||||||
|
<input type="hidden" name="hasMoreThanOnePage" value={hasMoreThanOnePage} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="direction"
|
||||||
|
value="previous"
|
||||||
|
class="btn btn-outline"
|
||||||
|
disabled={!hasPreviousPage || loading}
|
||||||
|
aria-label="View previous page of transactions"
|
||||||
|
on:click={() => {
|
||||||
|
if (transactions.length) {
|
||||||
|
endingBefore = transactions[0].id;
|
||||||
|
startingAfter = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="direction"
|
||||||
|
value="next"
|
||||||
|
class="btn btn-outline"
|
||||||
|
disabled={!hasNextPage || loading}
|
||||||
|
aria-label="View next page of transactions"
|
||||||
|
on:click={() => {
|
||||||
|
if (transactions.length) {
|
||||||
|
startingAfter = transactions[transactions.length - 1].id;
|
||||||
|
endingBefore = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Notification Preferences and Feedback -->
|
||||||
|
<div class="flex-none w-full lg:w-64 space-y-6">
|
||||||
|
<!-- Notification Preferences -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Notification Preferences</h2>
|
||||||
|
<form method="post" action="?/update_notifications" class="space-y-2">
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- checked={user.notifications.productUpdates} -->
|
||||||
|
<span>Product Updates</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- checked={user.notifications.billingReminders} -->
|
||||||
|
<span>Billing Reminders</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- checked={user.notifications.promotions} -->
|
||||||
|
<span>Promotional Emails</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Feedback Section -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Feedback</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We'd love to hear your thoughts! Please feel free to provide any feedback.
|
||||||
|
</p>
|
||||||
|
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-error w-full" disabled={loading}>Delete My Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUserCredits, getUserCreditTransactions } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return { error: 'User not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creditsRemaining = await getUserCredits(userId).catch((error) => {
|
||||||
|
logger.error('Failed to fetch user credits:', error);
|
||||||
|
throw new Error('Unable to retrieve credit balance');
|
||||||
|
});
|
||||||
|
const transactions = await getUserCreditTransactions(userId).catch((error) => {
|
||||||
|
logger.error('Failed to fetch credit transactions:', error);
|
||||||
|
throw new Error('Unable to retrieve transaction history');
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthlyAggregates = transactions.reduce((acc, transaction) => {
|
||||||
|
const date = new Date(transaction.created_at);
|
||||||
|
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||||
|
|
||||||
|
if (!acc[month]) acc[month] = { credits_added: 0, credits_deducted: 0 };
|
||||||
|
|
||||||
|
if (transaction.credits_change > 0) {
|
||||||
|
acc[month].credits_added += transaction.credits_change;
|
||||||
|
} else {
|
||||||
|
acc[month].credits_deducted += transaction.credits_change;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
creditsRemaining,
|
||||||
|
transactions,
|
||||||
|
monthlyAggregates
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Chart, { type ChartData } from 'chart.js/auto';
|
||||||
|
import { DataTable } from 'simple-datatables';
|
||||||
|
import 'simple-datatables/dist/style.css';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
const { creditsRemaining, transactions, monthlyAggregates } = data;
|
||||||
|
|
||||||
|
let chartCanvas: HTMLCanvasElement | null = null;
|
||||||
|
let creditsChart: Chart | null = null;
|
||||||
|
let chartType: 'bar' | 'line' = 'bar';
|
||||||
|
let chartData: ChartData | null = null;
|
||||||
|
|
||||||
|
const monthlyLabels = Object.keys(monthlyAggregates).reverse();
|
||||||
|
const creditsAdded = monthlyLabels.map((month) => monthlyAggregates[month].credits_added);
|
||||||
|
const creditsDeducted = monthlyLabels.map((month) =>
|
||||||
|
Math.abs(monthlyAggregates[month].credits_deducted)
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedTransactions = transactions.reduce((acc, transaction) => {
|
||||||
|
const date = new Date(transaction.created_at);
|
||||||
|
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||||
|
if (!acc[month]) acc[month] = [];
|
||||||
|
acc[month].push(transaction);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
let cumulativeBalance = 0;
|
||||||
|
const cumulativeBalanceData = monthlyLabels.map((month) => {
|
||||||
|
const monthTransactions = groupedTransactions[month] || [];
|
||||||
|
const monthCreditsChange = monthTransactions.reduce((sum, transaction) => {
|
||||||
|
return sum + transaction.credits_change;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
cumulativeBalance += monthCreditsChange;
|
||||||
|
|
||||||
|
return cumulativeBalance;
|
||||||
|
});
|
||||||
|
function initialiseChartData() {
|
||||||
|
if (chartType === 'bar') {
|
||||||
|
chartData = {
|
||||||
|
labels: monthlyLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Credits Added',
|
||||||
|
data: creditsAdded,
|
||||||
|
backgroundColor: 'green'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Credits Deducted',
|
||||||
|
data: creditsDeducted,
|
||||||
|
backgroundColor: 'red'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else if (chartType === 'line') {
|
||||||
|
chartData = {
|
||||||
|
labels: monthlyLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Cumulative Balance',
|
||||||
|
data: cumulativeBalanceData,
|
||||||
|
borderColor: 'blue',
|
||||||
|
fill: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(type: 'bar' | 'line') {
|
||||||
|
chartType = type;
|
||||||
|
initialiseChartData();
|
||||||
|
if (creditsChart) {
|
||||||
|
creditsChart.config.type = chartType;
|
||||||
|
creditsChart.data = chartData;
|
||||||
|
creditsChart.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initialiseChartData();
|
||||||
|
if (chartCanvas && chartData) {
|
||||||
|
creditsChart = new Chart(chartCanvas, {
|
||||||
|
type: chartType,
|
||||||
|
data: chartData,
|
||||||
|
options: { responsive: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataTable = new DataTable('#transaction-history');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="flex flex-col items-center p-4">
|
||||||
|
<h1 class="text-3xl font-bold mb-4">Credit Dashboard</h1>
|
||||||
|
<p class="text-lg">Credits Remaining: {creditsRemaining}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center my-4">
|
||||||
|
<button on:click={() => updateChart('bar')} class:font-bold={chartType === 'bar'}
|
||||||
|
>Monthly Usage</button
|
||||||
|
>
|
||||||
|
<button on:click={() => updateChart('line')} class:font-bold={chartType === 'line'}
|
||||||
|
>Cumulative Balance</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full md:w-3/4 mt-6">
|
||||||
|
<canvas bind:this={chartCanvas} class="my-8"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Transaction History</h2>
|
||||||
|
<div class="overflow-x-auto w-full md:w-3/4">
|
||||||
|
{#if transactions.length === 0}
|
||||||
|
<p class="text-lg">No transactions found.</p>
|
||||||
|
{:else}
|
||||||
|
<table id="transaction-history" class="table-auto w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th class="px-4 py-2">Date</th>
|
||||||
|
<th class="px-4 py-2">Description</th>
|
||||||
|
<th class="px-4 py-2">Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each transactions as transaction}
|
||||||
|
<tr class="border-b hover:bg-gray-100">
|
||||||
|
<td class="px-4 py-2">{new Date(transaction.created_at).toLocaleDateString()}</td>
|
||||||
|
<td class="px-4 py-2">{transaction.description}</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-2"
|
||||||
|
class:text-green-600={transaction.credits_change > 0}
|
||||||
|
class:text-red-600={transaction.credits_change < 0}
|
||||||
|
>
|
||||||
|
{transaction.credits_change > 0 ? '+' : ''}{transaction.credits_change}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.font-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+10
-177
@@ -1,34 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.postcss';
|
import '../app.postcss';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { isBrowser } from '@supabase/ssr';
|
import { isBrowser } from '@supabase/ssr';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
import { invalidate } from '$app/navigation';
|
import { invalidate } from '$app/navigation';
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
|
||||||
import CookieConsent from '$lib/components/CookieConsent.svelte';
|
import CookieConsent from '$lib/components/CookieConsent.svelte';
|
||||||
import NavLinks from '$lib/components/NavLinks.svelte';
|
import Nav from '$lib/components/navigation/Nav.svelte';
|
||||||
import MagicLink from '$lib/components/MagicLink.svelte';
|
import Toast from '$lib/components/shared/Toast.svelte';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase, session } = data;
|
let { supabase, session } = data;
|
||||||
$: ({ supabase, session } = data);
|
$: ({ supabase, session } = data);
|
||||||
|
|
||||||
let localBasket: Basket;
|
|
||||||
const unsubscribeToBasket = basket.subscribe((value) => {
|
|
||||||
localBasket = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
let localGeneral: any;
|
|
||||||
const unsubscribeToGeneralStore = general.subscribe((value) => {
|
|
||||||
localGeneral = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
unsubscribeToBasket();
|
|
||||||
unsubscribeToGeneralStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
let cookiesAccepted = false;
|
let cookiesAccepted = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -47,12 +29,6 @@
|
|||||||
|
|
||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
let isMobileMenuOpen = false;
|
|
||||||
|
|
||||||
function toggleMobileMenu() {
|
|
||||||
isMobileMenuOpen = !isMobileMenuOpen;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -69,109 +45,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="bg-primary text-primary-content p-4">
|
<Nav {session} {supabase} />
|
||||||
<div class="container mx-auto flex justify-between items-center">
|
|
||||||
<a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
|
|
||||||
|
|
||||||
<!-- Hamburger Menu Button (Mobile) -->
|
|
||||||
<button on:click={toggleMobileMenu} class="block md:hidden text-white focus:outline-none">
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h8m-8 6h16"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Navbar Links (hidden on mobile, visible on larger screens) -->
|
|
||||||
<div class="hidden md:flex space-x-4">
|
|
||||||
<ul class="flex flex-col md:flex-row md:space-x-4">
|
|
||||||
<NavLinks isMobile={false} />
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Side: Basket & User Account -->
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<!-- Basket/Checkout Icon -->
|
|
||||||
<a href="/checkout" class="btn btn-ghost btn-circle">
|
|
||||||
<div class="indicator">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- User Account Dropdown -->
|
|
||||||
<details class="dropdown dropdown-end">
|
|
||||||
<summary tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{#if session?.user}
|
|
||||||
<div class="user-circle text-primary-content border-primary-content">
|
|
||||||
{session?.user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
{#if session?.user}
|
|
||||||
<ul
|
|
||||||
tabindex="-1"
|
|
||||||
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
|
|
||||||
>
|
|
||||||
<li><a href="/account">Account</a></li>
|
|
||||||
<li><a href="/settings">Settings</a></li>
|
|
||||||
<li><SignOut {supabase} /></li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
|
|
||||||
>
|
|
||||||
<MagicLink {supabase} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
|
||||||
<div
|
|
||||||
class="bg-base-100 text-base-content shadow rounded-box p-4 transition duration-300 ease-in-out md:hidden"
|
|
||||||
class:block={isMobileMenuOpen}
|
|
||||||
class:hidden={!isMobileMenuOpen}
|
|
||||||
>
|
|
||||||
<ul class="flex flex-col space-y-4">
|
|
||||||
<NavLinks isMobile={true} />
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -195,8 +69,12 @@
|
|||||||
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
|
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>Copyright © 2024 - All right reserved</p>
|
<p>Copyright © 2024 - All rights reserved</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div class="grid-flow-col gap-4">
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
<a href="/cookies">Cookie Policy</a>
|
||||||
|
</div>
|
||||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<svg
|
<svg
|
||||||
@@ -237,52 +115,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div
|
<Toast />
|
||||||
id={localGeneral.toastType == 'success' ? 'success-toast' : 'error-toast'}
|
|
||||||
role="alert"
|
|
||||||
class="alert {localGeneral.toastType == 'success'
|
|
||||||
? 'alert-success'
|
|
||||||
: 'alert-error'} fixed top-20 right-3 max-w-52 text-white {localGeneral.hideToast
|
|
||||||
? 'hidden'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{#if localGeneral.toastType == 'success'}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
xml:space="preserve"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
><g
|
|
||||||
><g id="Error_1_"
|
|
||||||
><g id="Error">
|
|
||||||
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
|
|
||||||
<path
|
|
||||||
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
|
|
||||||
id="Exclamatory_x5F_Sign"
|
|
||||||
style="fill:#D72828;"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g></g
|
|
||||||
></svg
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span>{localGeneral.toastMessage == '' ? 'Added to basket' : localGeneral.toastMessage}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped lang="postcss">
|
||||||
.user-circle {
|
.user-circle {
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect(303, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: user } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.select(`name`)
|
|
||||||
.eq('id', session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return { session, user };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const name = formData.get('name') as string;
|
|
||||||
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return fail(401, { name });
|
|
||||||
}
|
|
||||||
const { error } = await supabase.from('users').update({
|
|
||||||
name: name,
|
|
||||||
}).eq('id', session?.user.id);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return fail(500, {
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
signout: async ({ locals: { supabase, safeGetSession } }) => {
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
if (session) {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
redirect(303, '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export let data;
|
|
||||||
export let form;
|
|
||||||
|
|
||||||
let { session, supabase, user } = data;
|
|
||||||
$: ({ session, supabase, user } = data);
|
|
||||||
|
|
||||||
let profileForm: HTMLFormElement;
|
|
||||||
let loading = false;
|
|
||||||
let name: string = user?.name ?? '';
|
|
||||||
|
|
||||||
const handleSubmit: SubmitFunction = () => {
|
|
||||||
loading = true;
|
|
||||||
return async () => {
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignOut: SubmitFunction = () => {
|
|
||||||
loading = true;
|
|
||||||
return async ({ update }) => {
|
|
||||||
loading = false;
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-widget">
|
|
||||||
<form
|
|
||||||
class="form-widget"
|
|
||||||
method="post"
|
|
||||||
action="?/update"
|
|
||||||
use:enhance={handleSubmit}
|
|
||||||
bind:this={profileForm}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label for="name">Name</label>
|
|
||||||
<input id="name" name="name" type="text" value={form?.name ?? name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
class="button block primary"
|
|
||||||
value={loading ? 'Loading...' : 'Update'}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="?/signout" use:enhance={handleSignOut}>
|
|
||||||
<div>
|
|
||||||
<button class="button block" disabled={loading}>Sign Out</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
|
|||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
// Create a checkout session
|
// Create a checkout session
|
||||||
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
@@ -34,7 +35,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
|
|||||||
try {
|
try {
|
||||||
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Stripe customer ID:', error);
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCheckoutSessionObject = {
|
const stripeCheckoutSessionObject = {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals: { safeGetSession } }) => {
|
|
||||||
const stripeCustomerId = url.searchParams.get('stripeCustomerId');
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
if (!session || !stripeCustomerId) {
|
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertCustomerToSupabase(session?.user.id, stripeCustomerId);
|
|
||||||
|
|
||||||
return json({ message: 'Customer created' });
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { deleteAccount } from '$lib/utils/supabase/admin';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return new Response('Invalid token', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAccount(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response('Failed to delete account', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Account deleted successfully', { status: 200 });
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const GET = async () => {
|
|
||||||
return json(await getSomeData());
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getSomeData() {
|
|
||||||
// Do your async fetching here
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
|
|||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
// Create a subscription checkout session
|
// Create a subscription checkout session
|
||||||
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
|
|||||||
try {
|
try {
|
||||||
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Stripe customer ID:', error);
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCheckoutSessionObject = {
|
const stripeCheckoutSessionObject = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
recordProductPurchase
|
recordProductPurchase
|
||||||
} from '$lib/utils/supabase/admin';
|
} from '$lib/utils/supabase/admin';
|
||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
try {
|
try {
|
||||||
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
|
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`⚠️ Webhook signature verification failed.`, err.message);
|
logger.error(`⚠️ Webhook signature verification failed.`, err.message);
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {}
|
body: {}
|
||||||
@@ -104,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log(`Unhandled event type ${event.type}`);
|
logger.info(`Unhandled event type ${event.type}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
|
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
let newPassword = '';
|
|
||||||
|
|
||||||
async function resetPassword() {
|
|
||||||
if (newPassword === '') {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Please enter a new password',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.updateUser({
|
|
||||||
password: newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating password:', error.message);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Error updating password. Please try again.',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Password updated successfully. Please sign in with your new password.',
|
|
||||||
toastType: 'success'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Update Password</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<h1>Update Password</h1>
|
|
||||||
<form on:submit|preventDefault={resetPassword}>
|
|
||||||
<label for="newPassword">New Password</label>
|
|
||||||
<input type="password" id="newPassword" bind:value={newPassword} />
|
|
||||||
<button type="submit">Update Password</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script src="https://js.stripe.com/v3/" lang="ts">
|
<script src="https://js.stripe.com/v3/" lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
import BasketItem from '$lib/components/checkout/BasketItem.svelte';
|
import BasketItem from '$lib/components/checkout/BasketItem.svelte';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase, session, url } = data;
|
let { supabase, session } = data;
|
||||||
$: ({ supabase, session, url } = data);
|
$: ({ supabase, session } = data);
|
||||||
|
|
||||||
let localBasket: Basket;
|
let localBasket: Basket;
|
||||||
const unsubscribe = basket.subscribe((value) => {
|
const unsubscribe = basket.subscribe((value) => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { getProductById } from '$lib/utils/supabase/admin';
|
import { getProductById, upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
|
export const load = async ({ fetch, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
export const load = async ({ fetch }) => {
|
|
||||||
const response = await fetch('/api/checkout/status');
|
const response = await fetch('/api/checkout/status');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch checkout status');
|
logger.error('Failed to fetch checkout status');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +27,11 @@ export const load = async ({ fetch }) => {
|
|||||||
lineItem.productDescription = product.description ?? '';
|
lineItem.productDescription = product.description ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the stripe customer id in the customers table
|
if (session?.user.id) {
|
||||||
const stripeCustomerId = checkoutSession.customer as string;
|
const stripeCustomerId = checkoutSession.customer as string;
|
||||||
const createCustomerResponse = await fetch(
|
await upsertCustomerToSupabase(session.user.id, stripeCustomerId);
|
||||||
'/api/checkout/create-customer?stripeCustomerId=' + stripeCustomerId
|
} else {
|
||||||
);
|
logger.error('User ID is undefined');
|
||||||
if (!createCustomerResponse.ok) {
|
|
||||||
console.error('Failed to create customer');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte';
|
import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte';
|
||||||
import { formatCurrency } from '$lib/utils/currency';
|
import { formatCurrency } from '$lib/utils/currency';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Actions } from '@sveltejs/kit';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { sendEmail } from '$lib/utils/brevo/email';
|
||||||
|
import { VITE_CONTACT_EMAIL } from '$env/static/private';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { isRateLimited } from '$lib/utils/rateLimiter';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const ip =
|
||||||
|
request.headers.get('x-forwarded-for') ?? request.headers.get('remote-addr') ?? 'unknown';
|
||||||
|
|
||||||
|
if (isRateLimited(ip)) {
|
||||||
|
return fail(429, { error: 'Too many requests. Please try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const message = formData.get('message') as string;
|
||||||
|
|
||||||
|
const emailContent = `
|
||||||
|
<p>You have a new contact form submission:</p>
|
||||||
|
<p><strong>Name:</strong> ${name}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<p>${message}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail('New Contact Form Submission', emailContent, VITE_CONTACT_EMAIL);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send email:', error);
|
||||||
|
return fail(400, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
let email = '';
|
||||||
|
let message = '';
|
||||||
|
let submitted = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
const handleSubmit: SubmitFunction = () => {
|
||||||
|
let loading = false;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
loading = true;
|
||||||
|
if (result.data && result.data.success) {
|
||||||
|
submitted = true;
|
||||||
|
} else {
|
||||||
|
error = result.error?.message || 'Form submission failed';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if submitted}
|
||||||
|
<div class="alert alert-success shadow-lg">
|
||||||
|
<div>
|
||||||
|
<span>Thank you for your message, {name}!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?"
|
||||||
|
use:enhance={handleSubmit}
|
||||||
|
class="max-w-lg mx-auto p-4 bg-base-200 rounded-lg shadow-md"
|
||||||
|
aria-labelledby="contact-form-title"
|
||||||
|
>
|
||||||
|
<h2 id="contact-form-title" class="text-xl font-bold mb-4">Contact Us</h2>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error shadow-lg mb-4">
|
||||||
|
<div>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text">Name *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
class="input input-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="email" class="label">
|
||||||
|
<span class="label-text">Email *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
class="input input-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="5"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="message" class="label">
|
||||||
|
<span class="label-text">Message *</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
bind:value={message}
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="1000"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<small class="text-sm mt-1">Maximum 1000 characters</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<button type="submit" class="btn btn-primary">Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
|
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
export const GET = async ({ url }) => {
|
export const GET = async ({ url }) => {
|
||||||
|
const limitParam: string | null = url.searchParams.get('limit');
|
||||||
|
const offsetParam: string | null = url.searchParams.get('offset');
|
||||||
try {
|
try {
|
||||||
const limitParam = url.searchParams.get('limit');
|
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
|
||||||
const offsetParam = url.searchParams.get('offset');
|
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
|
||||||
|
|
||||||
const limit = limitParam ? parseInt(limitParam, 10) : 10;
|
|
||||||
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
||||||
|
|
||||||
const { products, count } = await getActiveProductsWithPrices(limit, offset);
|
const { products, count } = await getActiveProductsWithPrices(limit, offset);
|
||||||
|
|
||||||
return json({ products, count });
|
return json({ products, count });
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error);
|
if (error instanceof Error) {
|
||||||
return json({ error: error.message }, { status: 500 });
|
logger.error('Failed to fetch products', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
params: {
|
||||||
|
limit: limitParam,
|
||||||
|
offset: offsetParam
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return json({ error: error.message }, { status: 500 });
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to fetch products', {
|
||||||
|
error,
|
||||||
|
params: {
|
||||||
|
limit: limitParam,
|
||||||
|
offset: offsetParam
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return json({ error }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
import { scheduleToast } from '$lib/stores/toastStore.js';
|
||||||
|
|
||||||
/** @type {import('./$types').PageData} */
|
/** @type {import('./$types').PageData} */
|
||||||
export let data;
|
export let data;
|
||||||
@@ -40,15 +40,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
general.update((value) => {
|
scheduleToast('Added to basket', 'success', 5000);
|
||||||
return { ...value, hideToast: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
create table account_deletion_requests (
|
||||||
|
user_id uuid references auth.users not null primary key,
|
||||||
|
token text not null,
|
||||||
|
requested_at timestamp with time zone default now() not null
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table account_deletion_requests enable row level security;
|
||||||
|
create policy "Can view own deletion request data" on account_deletion_requests
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
create policy "Can insert own deletion request data" on account_deletion_requests
|
||||||
|
for insert with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
ALTER TABLE customers
|
||||||
|
DROP CONSTRAINT IF EXISTS customers_id_fkey;
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT customers_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
DROP CONSTRAINT IF EXISTS subscriptions_user_id_fkey;
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
DROP CONSTRAINT IF EXISTS purchases_user_id_fkey;
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_credits
|
||||||
|
DROP CONSTRAINT IF EXISTS user_credits_user_id_fkey;
|
||||||
|
ALTER TABLE user_credits
|
||||||
|
ADD CONSTRAINT user_credits_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE credit_transactions
|
||||||
|
DROP CONSTRAINT IF EXISTS credit_transactions_user_id_fkey;
|
||||||
|
ALTER TABLE credit_transactions
|
||||||
|
ADD CONSTRAINT credit_transactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE account_deletion_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS account_deletion_requests_user_id_fkey;
|
||||||
|
ALTER TABLE account_deletion_requests
|
||||||
|
ADD CONSTRAINT account_deletion_requests_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_id_fkey;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Enable the "pg_cron" extension
|
||||||
|
create extension pg_cron with schema pg_catalog;
|
||||||
|
|
||||||
|
grant usage on schema cron to postgres;
|
||||||
|
grant all privileges on all tables in schema cron to postgres;
|
||||||
|
|
||||||
|
-- Create a function to delete expired tokens
|
||||||
|
CREATE OR REPLACE FUNCTION delete_expired_tokens()
|
||||||
|
RETURNS integer LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
deletion_count integer;
|
||||||
|
expiry_interval interval := '24 hours'::interval;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM account_deletion_requests
|
||||||
|
WHERE requested_at < NOW() - expiry_interval
|
||||||
|
RETURNING COUNT(*) INTO deletion_count;
|
||||||
|
RAISE NOTICE 'Deleted % expired token(s)', deletion_count;
|
||||||
|
RETURN deletion_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Schedule the function to run every hour
|
||||||
|
PERFORM cron.schedule(
|
||||||
|
'delete_expired_tokens',
|
||||||
|
'0 * * * *',
|
||||||
|
'SELECT delete_expired_tokens()'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'Failed to schedule token cleanup: %', SQLERRM;
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user