127 Commits

Author SHA1 Message Date
Olly 2294c6e276 Merge pull request #107 from jcreek/106-ensure-account-page-loads-when-there-are-no-transactions
fix(#106): Ensure account page loads when there are no transactions
2024-11-19 22:00:15 +00:00
Josh Creek d4f177f24b Merge pull request #108 from jcreek/docs-update
docs: update migration command
2024-11-19 22:00:04 +00:00
Josh Creek 8569048c3f fix(#106): Ensure account page loads when there are no transactions 2024-11-19 21:58:33 +00:00
OllyNicholass acb400231b docs: update migration command 2024-11-19 21:43:08 +00:00
Olly 3f037bf64e Merge pull request #105 from jcreek/98-improve-styling-of-credit-dashboard
98 improve styling of credit dashboard
2024-11-19 21:18:31 +00:00
OllyNicholass 5336d14c54 refactor: correct supabase client import 2024-11-19 21:14:54 +00:00
OllyNicholass cda0a1232a style(#98): add datatable styles 2024-11-19 21:12:40 +00:00
Olly 22940c53df Merge pull request #104 from jcreek/93-cookie-consent-compliance
feat(#93): add ability to reject cookies and in turn anayltic scripts
2024-11-19 21:02:56 +00:00
OllyNicholass acfdb75767 fix(#93): update logic to display consent when needed 2024-11-19 21:01:45 +00:00
Josh Creek 287fb78c0d Merge pull request #99 from jcreek/54-add-credit-usage-dashboard-for-users
54 add credit usage dashboard for users
2024-11-19 20:56:52 +00:00
Josh Creek 565d709904 Merge remote-tracking branch 'origin/master' into 54-add-credit-usage-dashboard-for-users 2024-11-19 20:51:04 +00:00
OllyNicholass 10b5747016 feat(#93): add ability to reject cookies and in turn anayltic scripts 2024-11-19 20:42:51 +00:00
Josh Creek 15d2f04754 Update src/routes/(user)/account/+page.svelte
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-19 20:39:44 +00:00
Josh Creek cf5771396c whoops 2024-11-19 20:33:52 +00:00
Josh Creek 13dd60428d refactor(#54): Move account into (user) and improve button styling 2024-11-19 20:32:31 +00:00
Olly b333ec2bef Merge pull request #97 from jcreek/74-add-token-expiry-mechanism-in-the-account_deletion_requests-table
feat(#74): Add token expiration for account deletion
2024-11-19 20:06:06 +00:00
Olly 319520e1ca Merge pull request #96 from jcreek/92-move-site-name-into-env-var
refactor(#92): Move site-name into env var
2024-11-19 20:05:29 +00:00
Olly 6268eacd6c Merge pull request #95 from jcreek/76-add-pagination-for-stripe-orders-on-account-page
76 add pagination for stripe orders on account page
2024-11-19 20:05:16 +00:00
Olly 4cbe02b8a0 Merge pull request #52 from jcreek/35-replace-navbar-with-a-better-one
35 replace navbar with a better one
2024-11-19 19:25:24 +00:00
OllyNicholass 9c01db944b feat(#35): schedule toast rather than display text 2024-11-19 19:23:17 +00:00
OllyNicholass 7f9958cb24 refactor(#35): remove readded pages 2024-11-19 18:49:26 +00:00
OllyNicholass 575b154ef4 Merge remote-tracking branch 'origin/master' into 35-replace-navbar-with-a-better-one 2024-11-18 23:34:09 +00:00
OllyNicholass 0189b20e0c refactor(#35): click anywhere handler 2024-11-18 23:20:18 +00:00
OllyNicholass ffde37ed15 fix(#35): password reset toast type 2024-11-18 23:16:49 +00:00
OllyNicholass 5a2b10b54b refactor(#35): announce toast notification 2024-11-18 23:15:09 +00:00
OllyNicholass 1113d509a8 refactor(#35): typesafe store subscription 2024-11-18 23:14:21 +00:00
OllyNicholass 8fcf650069 refactor(#35): accessible user button 2024-11-18 23:14:04 +00:00
OllyNicholass c25233f58e refactor(#35): hide other icons from SR 2024-11-18 23:09:10 +00:00
OllyNicholass 180325449c refactor(#35): hide basket icon from SR 2024-11-18 23:07:51 +00:00
OllyNicholass 1fbfb5c3d2 refactor(#35): accessible mobile nav button 2024-11-18 23:07:05 +00:00
OllyNicholass eae3ee99e8 refactor(#35): accessible basket nav link 2024-11-18 23:06:17 +00:00
OllyNicholass 87cd2b6c87 refactor(#35): whitespace 2024-11-18 23:03:47 +00:00
OllyNicholass 0ccf04fcbc refactor(#35): accessible navlinks 2024-11-18 23:03:17 +00:00
OllyNicholass 2174336512 refactor(#35): update type for supabase client in nav 2024-11-18 22:45:03 +00:00
OllyNicholass fb05f0b889 refactor(#35): split out toast and toast store 2024-11-18 22:41:41 +00:00
OllyNicholass 150c882827 refactor(#35): sign out to be accessible 2024-11-18 21:40:08 +00:00
OllyNicholass 0d8bc92dee refactor(#35): nav links to be accessible 2024-11-18 21:38:19 +00:00
Josh Creek 3b77f926e6 refactor(#54): Enhance error handling for database operations 2024-11-13 15:08:14 +00:00
Josh Creek 42663e7032 refactor(#54): Strongly type page load 2024-11-13 15:05:53 +00:00
Josh Creek 05a50a0ce0 refactor(#54): Improve month string formatting for consistent sorting 2024-11-13 15:05:04 +00:00
Josh Creek d62c92e527 feat(#54): Improve display of credit usage and accessibility 2024-11-13 14:51:13 +00:00
Josh Creek 93f41de185 feat(#54): Add credit usage dashboard 2024-11-13 12:00:59 +00:00
Josh Creek 77254c85fd refactor(#74): Enhance function robustness and configurability 2024-11-12 22:26:05 +00:00
Josh Creek 8788b90271 feat(#74): Add token expiration for account deletion 2024-11-12 21:53:01 +00:00
Josh Creek 61194d7132 refactor(#92): Move site-name into env var 2024-11-12 21:21:47 +00:00
Josh Creek b3c774b84c refactor(#76): Avoid using module-level variables for request-specific data 2024-11-12 20:59:48 +00:00
Josh Creek 5c5fc245b3 feat(#76): Improve accessibility 2024-11-12 20:25:26 +00:00
Josh Creek 981917cd6f chore(#76): Remove unused variables 2024-11-12 20:23:33 +00:00
Josh Creek 5de84d2ec1 feat(#76): Ensure pagination is only possible for valid pages 2024-11-12 20:09:01 +00:00
Josh Creek c77dc6ac47 feat(#76): Add pagination for stripe orders on account page 2024-11-11 20:04:02 +00:00
Josh Creek 62f7525238 Merge pull request #94 from jcreek/79-create-contact-page
feat(#79): Create contact page
2024-11-10 20:53:47 +00:00
Josh Creek c3125a0db1 feat(#79): Handle loading state 2024-11-10 19:58:50 +00:00
Josh Creek 3b1ffb21a6 feat(#79): Enhance form security and accessibility 2024-11-10 19:55:57 +00:00
Josh Creek 829d8cc031 feat(#79): Add rate limiting to contact page form 2024-11-10 19:45:40 +00:00
Josh Creek 6cdc0f0bde build(*): Fix broken closing tag preventing build 2024-11-10 19:40:59 +00:00
Josh Creek 4137f85190 fix(#79): Add check for result.data 2024-11-10 19:33:32 +00:00
Josh Creek 58842c6f1e feat(#79): Create contact page 2024-11-10 19:31:23 +00:00
Josh Creek b2bfd12a35 Merge pull request #71 from jcreek/60-if-you-sign-out-the-basket-doesnt-get-cleared
60 if you sign out the basket doesnt get cleared
2024-11-10 18:53:27 +00:00
Josh Creek 6a10ab5983 refactor(#60): Strongly type supabase client 2024-11-10 18:10:56 +00:00
Josh Creek 7ec5c5bb38 chore(#60): Remove logger from client side code 2024-11-06 21:09:29 +00:00
Josh Creek 4f4f122702 fix(#60): Fix logger import 2024-11-06 21:02:39 +00:00
Olly 19f9b077d6 Merge pull request #68 from jcreek/55-enable-account-closure
55 enable account closure
2024-11-06 20:51:30 +00:00
Josh Creek a8c702123e chore(#55): Fix whitespace 2024-11-06 18:03:49 +00:00
Josh Creek af4b7c4e4c chore(#55): Improve account deletion messaging 2024-11-06 17:55:50 +00:00
Josh Creek dc7f6ba18e chore(#60): Remove unused type 2024-11-06 17:50:31 +00:00
Josh Creek 0be329ff06 refactor(#60): Utilise errors when signing out 2024-11-06 17:49:52 +00:00
Josh Creek 39701b2155 fix(#60): Unsubscribe to basket store after logging out 2024-11-06 17:43:29 +00:00
Josh Creek ed6dc6db74 Merge branch 'master' into 60-if-you-sign-out-the-basket-doesnt-get-cleared 2024-11-05 22:41:56 +00:00
Josh Creek eec6e2041c chore(#55): Re-add pnpm lock 2024-11-05 22:40:25 +00:00
Josh Creek df8af5146f Merge branch 'master' into 55-enable-account-closure 2024-11-05 22:39:43 +00:00
Olly 7a91eb5173 Merge pull request #70 from jcreek/65-add-a-proper-error-logging-solution
65 add a proper error logging solution
2024-11-05 22:29:30 +00:00
OllyNicholass 57a800ef2c refactor(#65): add types to products page params 2024-11-05 22:13:20 +00:00
OllyNicholass 3cd3200d1e refactor(#65): add additional error handling & logs 2024-11-05 22:06:54 +00:00
OllyNicholass d329989230 refactor(#65): move scope of logged params
Type defs added where relevent
2024-11-05 21:44:48 +00:00
Josh Creek 026524a1b3 Merge branch 'master' into 65-add-a-proper-error-logging-solution 2024-11-05 21:07:55 +00:00
Olly a5a14a93ef Merge pull request #69 from jcreek/59-style-account-page
59 style account page
2024-11-05 20:57:49 +00:00
Olly f032005d8f Merge pull request #72 from jcreek/53-make-a-privacy-page
53 make a privacy page
2024-11-05 20:05:54 +00:00
Josh Creek 86ced6e7f9 refactor(#65): Enhance error logging with context 2024-11-04 19:12:27 +00:00
Josh Creek 8a251f775a refactor(#65): Enhance error logging with request context 2024-11-04 19:10:18 +00:00
Josh Creek ad30f04078 refactor(#59): Verify that 'transactions' is always defined before iteration 2024-11-04 19:00:23 +00:00
Josh Creek 506ceefe26 refactor(#59): Validate receipt links before showing the buttons 2024-11-04 18:56:56 +00:00
Josh Creek de565bb965 chore(#59): Remove debugging log 2024-11-04 18:51:57 +00:00
Josh Creek 3bbbd340c5 chore(#59): Improve clarity of error logs 2024-11-04 18:50:19 +00:00
Josh Creek d62b17ad65 docs(#55): Improve clarity around db migrations
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:38:13 +00:00
Josh Creek 259d83a71e refactor(#55): Improve error handling for requestAccountDeletion
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:32:52 +00:00
Josh Creek 26426f9804 feat(#55): Add error check for Brevo API key
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:28:37 +00:00
Josh Creek 1250261afe docs(#55): Add comments to .env.example
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:18:42 +00:00
Josh Creek f1f2fe99d7 feat(#59): Enable showing subscriptions and transactions 2024-11-01 20:12:08 +00:00
Josh Creek 3189d8932b feat(#59): Add styling to account page 2024-11-01 19:12:34 +00:00
Josh Creek fe1f49090c chore(#65): Comment out test code 2024-10-30 21:08:20 +00:00
Josh Creek 9253926c7d chore(*): Remove unused components 2024-10-30 21:06:28 +00:00
Josh Creek ddac66e3a9 feat(#65): Update example env 2024-10-30 21:06:20 +00:00
Josh Creek 3d89222eec feat(#65): Replace existing console logs 2024-10-30 21:06:12 +00:00
Josh Creek 2ef478e4b1 feat(#65): Add winston and axiom logging 2024-10-30 21:04:40 +00:00
Josh Creek 67b748b88e chore(*): Remove redundant sign out button 2024-10-30 18:48:27 +00:00
Josh Creek c23de036c7 feat(#60): Clear basket on sign out 2024-10-30 18:48:11 +00:00
Josh Creek 478ff600d3 feat(#53): Add policy link to cookie consent banner 2024-10-30 18:33:03 +00:00
Josh Creek cb1321825d feat(#53): Add policy links to footer 2024-10-30 18:32:45 +00:00
Josh Creek 464f365085 feat(#55): Enable sending account deletion emails through brevo npm package 2024-10-29 22:10:21 +00:00
OllyNicholass 7de7ccf9aa refactor: add issue ids to TODOs 2024-10-29 21:57:10 +00:00
OllyNicholass 8540a2c50f chore: add workspace vscode config for indentation 😉 2024-10-29 21:47:14 +00:00
OllyNicholass d49f4b2c38 refactor(#35): iNdEnTaTiOn 2024-10-29 21:46:52 +00:00
Olly b83b02fd12 Merge pull request #57 from jcreek/51-editing-the-input-for-basket-item-qty-doesnt-recalculate-subtotal
51 editing the input for basket item qty doesn't recalculate subtotal
2024-10-29 21:35:15 +00:00
OllyNicholass 550d9bac18 refactor(#35): rename dropdown handleClickAnywhere 2024-10-29 21:31:01 +00:00
OllyNicholass 21fe0cdab8 refactor(#35): fix indentation 2024-10-29 21:29:42 +00:00
OllyNicholass 00fb76ca4f refactor(#35): add keys to navlink loops 2024-10-29 21:29:09 +00:00
Josh Creek c4865337ee chore(*): Remove redundant example file 2024-10-29 21:20:34 +00:00
Josh Creek 33409bc375 feat(#55): Enable deleting account and anonymising purchases and subscriptions (no email sending) 2024-10-29 21:20:23 +00:00
Josh Creek 90875517da docs(#55): Add clearer migration commands 2024-10-29 21:17:10 +00:00
OllyNicholass 4be732005e feat(#35): make basket more prominent in nav 2024-10-29 20:44:45 +00:00
OllyNicholass 7c6afd169c chore: add stripe script to easily run local webhook 2024-10-29 20:43:18 +00:00
Josh Creek e4ef376064 feat(#53): Add cookie and privacy policies 2024-10-29 19:30:06 +00:00
Josh Creek 8bc8399b5f chore(*): Remove redundant page 2024-10-29 19:17:25 +00:00
Josh Creek f28f250fab fix(#51): Ensure the input for quantity is reactive 2024-10-29 19:17:05 +00:00
OllyNicholass 08a6f6d7cc refactor(#35): move nav components 2024-10-24 00:38:26 +01:00
Olly 243dc883ac Merge pull request #49 from jcreek/48-stripe-customer-creation-could-do-with-some-improved-security
refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint
2024-10-24 00:29:44 +01:00
OllyNicholass 3a5e961d54 feat(#35): add new mobile menu 2024-10-24 00:28:28 +01:00
OllyNicholass 6ed1b25583 refactor(#35): conditionally display basket indicator 2024-10-22 22:19:19 +01:00
OllyNicholass 867f0f43f6 refactor(#35): center text in avatar button 2024-10-22 22:18:59 +01:00
OllyNicholass a16959d7ba refactor(#35): use dropdown component for user/signin 2024-10-22 22:04:19 +01:00
OllyNicholass d6baf11a7c refactor(#35): add guard clause to escape event listener if dropdown is not open 2024-10-22 21:53:44 +01:00
OllyNicholass f20e856d44 refactor(#35): Split dropdown into standalone component 2024-10-22 21:43:31 +01:00
OllyNicholass ec8adbf807 refactor(#35): Add dropdown and closing logic to parent nav link 2024-10-22 21:35:05 +01:00
OllyNicholass 5b7eafc294 refactor(#35): split nav into components control via JSON 2024-10-22 20:48:21 +01:00
OllyNicholass 6959bcdd04 refactor: remove unused refs 2024-10-22 20:47:01 +01:00
OllyNicholass 090224fc70 refactor(#35): move nav into component 2024-10-22 20:15:02 +01:00
Josh Creek 24a70f123a refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint 2024-10-22 20:01:49 +01:00
53 changed files with 2558 additions and 724 deletions
+13 -1
View File
@@ -3,4 +3,16 @@ PUBLIC_SUPABASE_ANON_KEY=""
SUPABASE_SERVICE_ROLE_KEY=""
STRIPE_SECRET_KEY=""
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"
+4
View File
@@ -0,0 +1,4 @@
{
"editor.tabSize": 4,
"editor.formatOnSave": true
}
+11 -1
View File
@@ -79,7 +79,17 @@ There are two options to create a migration file:
## 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`
+7 -1
View File
@@ -6,6 +6,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"stripe": "stripe listen --forward-to localhost:5173/api/webhook/stripe",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
@@ -47,10 +48,15 @@
},
"type": "module",
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@getbrevo/brevo": "^2.2.0",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2",
"chart.js": "^4.4.6",
"simple-datatables": "^9.2.1",
"stripe": "^15.4.0",
"ts-debounce": "^4.0.0"
"ts-debounce": "^4.0.0",
"winston": "^3.15.0"
},
"engines": {
"node": ">=18.13.0"
+553
View File
File diff suppressed because it is too large Load Diff
+16 -6
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
export let cookiesAccepted;
let showBanner = false;
@@ -7,24 +9,32 @@
onMount(() => {
if (!cookiesAccepted) {
const storedValue = localStorage.getItem('cookiesAccepted');
showBanner = storedValue !== 'true';
showBanner = storedValue === null;
}
});
const acceptCookies = () => {
localStorage.setItem('cookiesAccepted', 'true');
const handleCookiesSubmit = (accepted: boolean) => {
localStorage.setItem('cookiesAccepted', accepted.toString());
showBanner = false;
};
</script>
{#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">
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>
<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
>
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(false)}
>Reject</button
>
</div>
{/if}
+52
View File
@@ -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: '&#x25BC;',
open: '&#x25B2;'
};
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>
+5 -19
View File
@@ -1,20 +1,18 @@
<script lang="ts">
import { scheduleToast } from '$lib/stores/toastStore.js';
export let supabase: any;
const SUCCESS_MESSAGE = 'Check your email for the magic link.';
let error = '',
message = '',
loading = false,
let loading = false,
email = '';
async function submit() {
error = '';
message = '';
loading = true;
const { error: err } = await supabase.auth.signInWithOtp({ email });
if (err) error = err.message;
else message = 'Check your email for the magic link.';
if (err) scheduleToast(err.message, 'error', 5000);
else scheduleToast(SUCCESS_MESSAGE, 'success', 5000);
loading = false;
}
@@ -37,18 +35,6 @@
<button class="btn btn-active btn-primary w-full" on:click disabled={loading}
>Send magic link</button
>
{#if message}
<div class="text-success">
{message}
</div>
{/if}
{#if error}
<div class="text-error">
{error}
</div>
{/if}
</form>
</div>
</div>
-23
View File
@@ -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">&#x25BC;</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>
-126
View File
@@ -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>
+34 -6
View File
@@ -1,5 +1,8 @@
<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();
function emitSignedOutEvent() {
@@ -7,14 +10,39 @@
}
// 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() {
// TODO use the error from the response
const { error } = await supabase.auth.signOut().then(() => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error signing out:', error.message);
alert('Error signing out');
return;
}
try {
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>
<button on:click={signOut}>Sign Out</button>
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
-68
View File
@@ -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 quantity = 0;
$: dispatch('changeQuantity', { itemId, quantity });
function reduceQuantityByOne() {
if (quantity > 1) {
quantity--;
dispatch('changeQuantity', { itemId, quantity });
} else {
dispatch('removeItem', { itemId });
}
@@ -22,7 +23,6 @@
function increaseQuantityByOne() {
quantity++;
dispatch('changeQuantity', { itemId, quantity });
}
</script>
+124
View File
@@ -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>
+65
View File
@@ -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}
-3
View File
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
+23
View File
@@ -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));
}
+8
View File
@@ -0,0 +1,8 @@
export interface NavLinkItem {
text: string;
href?: string;
isParent?: boolean;
children?: NavLinkItem[];
isSignout?: boolean;
isUserAccount?: boolean;
}
+5
View File
@@ -0,0 +1,5 @@
export type Toast = {
hideToast: boolean,
toastMessage: string,
toastType: 'success' | 'error'
}
+26
View File
@@ -34,6 +34,32 @@ export type Database = {
}
public: {
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: {
Row: {
created_at: string
+34
View File
@@ -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 };
+44
View File
@@ -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;
+30
View File
@@ -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
View File
@@ -4,6 +4,8 @@ import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
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 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]);
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) => {
@@ -61,7 +63,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
if (upsertError?.message.includes('foreign key constraint')) {
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 upsertPriceRecord(price, retryCount + 1, maxRetries);
} else {
@@ -72,7 +74,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
} else if (upsertError) {
throw new Error(`Price insert/update failed: ${upsertError.message}`);
} 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()
.eq('id', product.id);
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 { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
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) => {
@@ -254,11 +256,18 @@ const manageSubscriptionStatusChange = async (
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
};
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
try {
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
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.
// // 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) => {
console.log(
logger.info(
`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}`);
}
} 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) => {
// Check if user has purchased the product
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId);
try {
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
.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) {
// User has purchased the product
return true;
if (purchases && purchases.length > 0) {
// User has purchased the product
return true;
}
} catch (error) {
logger.error(error);
}
// Check if user has an active subscription for the product
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId)
.in('status', ['active', 'trialing']);
try {
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.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) {
// User has an active subscription
return true;
if (subscriptions && subscriptions.length > 0) {
// User has an active subscription
return true;
}
} catch (error) {
logger.error(error);
}
return false;
@@ -381,7 +398,7 @@ const updateUserCredits = async (userId: string, creditsChange: number, descript
await logCreditTransaction(userId, creditsChange, description);
console.log(
logger.info(
`${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 {
await updateUserCredits(userId, creditsToAdd, description);
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -398,7 +415,7 @@ const deductCredits = async (userId: string, creditsToDeduct: number, descriptio
try {
await updateUserCredits(userId, -creditsToDeduct, description);
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -434,6 +451,28 @@ const getUserCredits = async (userId: string) => {
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 & {
prices: Price[];
actualPrice?: number;
@@ -480,6 +519,158 @@ const getProductById = async (productId: string) => {
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 {
upsertProductRecord,
upsertPriceRecord,
@@ -495,5 +686,9 @@ export {
getActiveProductsWithPrices,
getProductById,
upsertCustomerToSupabase,
getStripeCustomerId
getStripeCustomerId,
getUserSubscriptions,
getUserTransactions,
requestAccountDeletion,
deleteAccount
};
+6
View File
@@ -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>
+125
View File
@@ -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>
+134
View File
@@ -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' });
}
}
};
+267
View File
@@ -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>
+53
View File
@@ -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
};
}
};
+164
View File
@@ -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
View File
@@ -1,34 +1,16 @@
<script lang="ts">
import '../app.postcss';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
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 SignOut from '$lib/components/SignOut.svelte';
import CookieConsent from '$lib/components/CookieConsent.svelte';
import NavLinks from '$lib/components/NavLinks.svelte';
import MagicLink from '$lib/components/MagicLink.svelte';
import Nav from '$lib/components/navigation/Nav.svelte';
import Toast from '$lib/components/shared/Toast.svelte';
export let data;
let { 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;
onMount(() => {
@@ -47,12 +29,6 @@
return () => data.subscription.unsubscribe();
});
let isMobileMenuOpen = false;
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
</script>
<svelte:head>
@@ -69,109 +45,7 @@
</svelte:head>
<header>
<nav class="bg-primary text-primary-content p-4">
<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>
<Nav {session} {supabase} />
</header>
<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"
></path>
</svg>
<p>Copyright © 2024 - All right reserved</p>
<p>Copyright © 2024 - All rights reserved</p>
</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">
<a href="/">
<svg
@@ -237,52 +115,7 @@
</nav>
</footer>
<div
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>
<Toast />
<style scoped lang="postcss">
.user-circle {
-53
View File
@@ -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, '/');
}
}
};
-59
View File
@@ -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 -1
View File
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
import { stripe as stripeClient } from '$lib/utils/stripe';
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
import type Stripe from 'stripe';
import logger from '$lib/utils/logger/logger';
// Create a checkout session
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
@@ -34,7 +35,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
try {
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
} catch (error) {
console.error('Error fetching Stripe customer ID:', error);
logger.error('Error fetching Stripe customer ID:', error);
}
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' });
};
+18
View File
@@ -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 -1
View File
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
import { stripe as stripeClient } from '$lib/utils/stripe';
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
import type Stripe from 'stripe';
import logger from '$lib/utils/logger/logger';
// Create a subscription checkout session
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
try {
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
} catch (error) {
console.error('Error fetching Stripe customer ID:', error);
logger.error('Error fetching Stripe customer ID:', error);
}
const stripeCheckoutSessionObject = {
+3 -2
View File
@@ -10,6 +10,7 @@ import {
recordProductPurchase
} from '$lib/utils/supabase/admin';
import { stripe as stripeClient } from '$lib/utils/stripe';
import logger from '$lib/utils/logger/logger';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ request }) => {
try {
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
} catch (err) {
console.error(`⚠️ Webhook signature verification failed.`, err.message);
logger.error(`⚠️ Webhook signature verification failed.`, err.message);
return {
status: 400,
body: {}
@@ -104,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
break;
}
default:
console.log(`Unhandled event type ${event.type}`);
logger.info(`Unhandled event type ${event.type}`);
}
} catch (err) {
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>
+3 -3
View File
@@ -1,11 +1,11 @@
<script src="https://js.stripe.com/v3/" lang="ts">
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';
export let data;
let { supabase, session, url } = data;
$: ({ supabase, session, url } = data);
let { supabase, session } = data;
$: ({ supabase, session } = data);
let localBasket: Basket;
const unsubscribe = basket.subscribe((value) => {
+11 -10
View File
@@ -1,11 +1,14 @@
import { redirect } from '@sveltejs/kit';
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');
if (!response.ok) {
console.error('Failed to fetch checkout status');
logger.error('Failed to fetch checkout status');
return;
}
@@ -24,13 +27,11 @@ export const load = async ({ fetch }) => {
lineItem.productDescription = product.description ?? '';
}
// Save the stripe customer id in the customers table
const stripeCustomerId = checkoutSession.customer as string;
const createCustomerResponse = await fetch(
'/api/checkout/create-customer?stripeCustomerId=' + stripeCustomerId
);
if (!createCustomerResponse.ok) {
console.error('Failed to create customer');
if (session?.user.id) {
const stripeCustomerId = checkoutSession.customer as string;
await upsertCustomerToSupabase(session.user.id, stripeCustomerId);
} else {
logger.error('User ID is undefined');
}
return {
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
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 { formatCurrency } from '$lib/utils/currency';
import type { PageData } from './$types';
+38
View File
@@ -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 });
}
}
};
+103
View File
@@ -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}
+26 -8
View File
@@ -1,19 +1,37 @@
import { json } from '@sveltejs/kit';
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
import logger from '$lib/utils/logger/logger';
export const GET = async ({ url }) => {
const limitParam: string | null = url.searchParams.get('limit');
const offsetParam: string | null = url.searchParams.get('offset');
try {
const limitParam = url.searchParams.get('limit');
const offsetParam = url.searchParams.get('offset');
const limit = limitParam ? parseInt(limitParam, 10) : 10;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
const { products, count } = await getActiveProductsWithPrices(limit, offset);
return json({ products, count });
} catch (error) {
console.error(error);
return json({ error: error.message }, { status: 500 });
} catch (error: unknown) {
if (error instanceof Error) {
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 });
}
}
};
+3 -11
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { redirect } from '@sveltejs/kit';
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
import { general } from '$lib/stores/generalStore.js';
import { basket, type Basket } from '$lib/stores/basket.js';
import { scheduleToast } from '$lib/stores/toastStore.js';
/** @type {import('./$types').PageData} */
export let data;
@@ -40,15 +40,7 @@
}
});
general.update((value) => {
return { ...value, hideToast: false };
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true };
});
}, 5000);
scheduleToast('Added to basket', 'success', 5000);
}
</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;
$$;