69 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
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
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
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
OllyNicholass 08a6f6d7cc refactor(#35): move nav components 2024-10-24 00:38:26 +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
31 changed files with 939 additions and 264 deletions
+3 -1
View File
@@ -13,4 +13,6 @@ VITE_BREVO_SENDER_EMAIL="noreply@example.com"
# Display name for the sender email # Display name for the sender email
VITE_BREVO_SENDER_NAME="No Reply" VITE_BREVO_SENDER_NAME="No Reply"
# Contact email for the contact page form to send to # Contact email for the contact page form to send to
VITE_CONTACT_EMAIL="" 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
}
+2
View File
@@ -83,6 +83,8 @@ To do so without resetting the database, you can use the following command:
`npx supabase migration up` `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. > 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: And for the database in the cloud:
+3
View File
@@ -6,6 +6,7 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"stripe": "stripe listen --forward-to localhost:5173/api/webhook/stripe",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
@@ -51,6 +52,8 @@
"@getbrevo/brevo": "^2.2.0", "@getbrevo/brevo": "^2.2.0",
"@supabase/ssr": "^0.4.0", "@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2", "@supabase/supabase-js": "^2.44.2",
"chart.js": "^4.4.6",
"simple-datatables": "^9.2.1",
"stripe": "^15.4.0", "stripe": "^15.4.0",
"ts-debounce": "^4.0.0", "ts-debounce": "^4.0.0",
"winston": "^3.15.0" "winston": "^3.15.0"
+37
View File
@@ -20,6 +20,12 @@ importers:
'@supabase/supabase-js': '@supabase/supabase-js':
specifier: ^2.44.2 specifier: ^2.44.2
version: 2.45.4 version: 2.45.4
chart.js:
specifier: ^4.4.6
version: 4.4.6
simple-datatables:
specifier: ^9.2.1
version: 9.2.1
stripe: stripe:
specifier: ^15.4.0 specifier: ^15.4.0
version: 15.12.0 version: 15.12.0
@@ -937,6 +943,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1533,6 +1542,10 @@ packages:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chart.js@4.4.6:
resolution: {integrity: sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==}
engines: {pnpm: '>=8'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -1670,6 +1683,9 @@ packages:
resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@4.3.7: debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1731,6 +1747,9 @@ packages:
didyoumean@1.2.2: didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
diff-dom@5.1.4:
resolution: {integrity: sha512-TSEaVdVGictY1KHg7VpVw2nuM02YKC9C8/qBkGiCnkiAybVbu1zQTMj2/dnVLRO7Z62UsqzHGpXweiOj5/jaZg==}
dir-glob@3.0.1: dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2993,6 +3012,9 @@ packages:
simple-concat@1.0.1: simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-datatables@9.2.1:
resolution: {integrity: sha512-R+KRyZ4GNQdqj5IoW08OYi9FHqCbndbw+MfdOJDnwOGs6me9+BhbNuj2QHZR/GXQ04TLra43Uj2/Lk1KcLiJRw==}
simple-get@4.0.1: simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
@@ -4530,6 +4552,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@kurkle/color@0.3.2': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -5194,6 +5218,10 @@ snapshots:
chalk@5.3.0: {} chalk@5.3.0: {}
chart.js@4.4.6:
dependencies:
'@kurkle/color': 0.3.2
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -5337,6 +5365,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.1 is-data-view: 1.0.1
dayjs@1.11.13: {}
debug@4.3.7: debug@4.3.7:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -5386,6 +5416,8 @@ snapshots:
didyoumean@1.2.2: {} didyoumean@1.2.2: {}
diff-dom@5.1.4: {}
dir-glob@3.0.1: dir-glob@3.0.1:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
@@ -6699,6 +6731,11 @@ snapshots:
simple-concat@1.0.1: {} simple-concat@1.0.1: {}
simple-datatables@9.2.1:
dependencies:
dayjs: 1.11.13
diff-dom: 5.1.4
simple-get@4.0.1: simple-get@4.0.1:
dependencies: dependencies:
decompress-response: 6.0.0 decompress-response: 6.0.0
+13 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
export let cookiesAccepted; export let cookiesAccepted;
let showBanner = false; let showBanner = false;
@@ -7,26 +9,32 @@
onMount(() => { onMount(() => {
if (!cookiesAccepted) { if (!cookiesAccepted) {
const storedValue = localStorage.getItem('cookiesAccepted'); const storedValue = localStorage.getItem('cookiesAccepted');
showBanner = storedValue !== 'true'; showBanner = storedValue === null;
} }
}); });
const acceptCookies = () => { const handleCookiesSubmit = (accepted: boolean) => {
localStorage.setItem('cookiesAccepted', 'true'); localStorage.setItem('cookiesAccepted', accepted.toString());
showBanner = false; showBanner = false;
}; };
</script> </script>
{#if showBanner} {#if showBanner}
<div class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4"> <div
class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4"
transition:slide={{ duration: 200, easing: cubicInOut }}
>
<p class="text-sm"> <p class="text-sm">
We use cookies to ensure you get the best experience on our website. By continuing, you agree We use cookies to ensure you get the best experience on our website. By continuing, you agree
to our use of cookies. For details, please see our <a href="/privacy" class="underline" to our use of cookies. For details, please see our <a href="/privacy" class="underline"
>Privacy Policy</a >Privacy Policy</a
>. >.
</p> </p>
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies} <button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(true)}
>Accept</button >Accept</button
> >
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(false)}
>Reject</button
>
</div> </div>
{/if} {/if}
+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"> <script lang="ts">
import { scheduleToast } from '$lib/stores/toastStore.js';
export let supabase: any; export let supabase: any;
const SUCCESS_MESSAGE = 'Check your email for the magic link.';
let error = '', let loading = false,
message = '',
loading = false,
email = ''; email = '';
async function submit() { async function submit() {
error = '';
message = '';
loading = true; loading = true;
const { error: err } = await supabase.auth.signInWithOtp({ email }); const { error: err } = await supabase.auth.signInWithOtp({ email });
if (err) error = err.message; if (err) scheduleToast(err.message, 'error', 5000);
else message = 'Check your email for the magic link.'; else scheduleToast(SUCCESS_MESSAGE, 'success', 5000);
loading = false; loading = false;
} }
@@ -37,18 +35,6 @@
<button class="btn btn-active btn-primary w-full" on:click disabled={loading} <button class="btn btn-active btn-primary w-full" on:click disabled={loading}
>Send magic link</button >Send magic link</button
> >
{#if message}
<div class="text-success">
{message}
</div>
{/if}
{#if error}
<div class="text-error">
{error}
</div>
{/if}
</form> </form>
</div> </div>
</div> </div>
-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>
+1 -1
View File
@@ -45,4 +45,4 @@
} }
</script> </script>
<button on:click={signOut}>Sign Out</button> <button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
+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'
}
+6 -2
View File
@@ -1,6 +1,10 @@
import { createLogger, transports, format } from 'winston'; import { createLogger, transports, format } from 'winston';
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston'; import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
import { VITE_AXIOM_DATASET, VITE_AXIOM_TOKEN } from '$env/static/private'; import {
VITE_AXIOM_DATASET,
VITE_AXIOM_TOKEN,
VITE_LOGGER_SERVICE_NAME
} from '$env/static/private';
const axiomTransport = new AxiomTransport({ const axiomTransport = new AxiomTransport({
dataset: VITE_AXIOM_DATASET, dataset: VITE_AXIOM_DATASET,
@@ -9,7 +13,7 @@ const axiomTransport = new AxiomTransport({
const logger = createLogger({ const logger = createLogger({
level: 'info', level: 'info',
defaultMeta: { service: 'example-site' }, // Change this so you can identify your website if you have multiple logging into Axiom 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: format.combine(
format.errors({ stack: true }), // Captures error stack traces format.errors({ stack: true }), // Captures error stack traces
format.timestamp(), format.timestamp(),
+43 -5
View File
@@ -451,6 +451,28 @@ const getUserCredits = async (userId: string) => {
return creditData.credits_remaining; return creditData.credits_remaining;
}; };
/**
* Fetches the credit transaction history for a specific user.
*
* @param userId - The ID of the user whose transactions are to be fetched.
* @returns Array of transaction records containing credits_change, description, and created_at.
*/
export const getUserCreditTransactions = async (userId: string) => {
if (!userId) throw new Error('User ID is required');
const { data, error } = await supabaseAdmin
.from('credit_transactions')
.select('credits_change, description, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false }); // Orders transactions with the latest ones first
if (error) {
throw new Error(`Error fetching transactions: ${error.message}`);
}
return data || [];
};
type ProductWithPrices = Product & { type ProductWithPrices = Product & {
prices: Price[]; prices: Price[];
actualPrice?: number; actualPrice?: number;
@@ -590,7 +612,12 @@ const getUserSubscriptions = async (userId: string) => {
} }
}; };
const getUserTransactions = async (userId: string) => { const getUserTransactions = async (
userId: string,
perPage: number,
startingAfter: string | undefined = undefined,
endingBefore: string | undefined = undefined
) => {
try { try {
// Step 1: Retrieve the customer ID associated with the user // Step 1: Retrieve the customer ID associated with the user
const { data, error } = await supabaseAdmin const { data, error } = await supabaseAdmin
@@ -600,8 +627,8 @@ const getUserTransactions = async (userId: string) => {
.single(); .single();
if (error) { if (error) {
console.error('Error retrieving user data:', error.message); logger.error('Error retrieving user data:', error.message);
throw new Error('Failed to fetch user data'); return;
} }
const customerId = data?.stripe_customer_id; const customerId = data?.stripe_customer_id;
@@ -610,10 +637,21 @@ const getUserTransactions = async (userId: string) => {
} }
// Step 2: Fetch charge transactions from Stripe // Step 2: Fetch charge transactions from Stripe
const charges = await stripeClient.charges.list({ customer: customerId }); 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 // Step 3: Format transaction data
const transactions = charges.data.map((charge) => ({ const transactions = charges.data.map((charge) => ({
id: charge.id,
amount: (charge.amount / 100).toFixed(2), amount: (charge.amount / 100).toFixed(2),
currency: charge.currency.toUpperCase(), currency: charge.currency.toUpperCase(),
description: charge.description ?? 'No description provided', description: charge.description ?? 'No description provided',
@@ -626,7 +664,7 @@ const getUserTransactions = async (userId: string) => {
receipt_url: charge.receipt_url receipt_url: charge.receipt_url
})); }));
return transactions; return { transactions, hasNextPage };
} catch (error) { } catch (error) {
console.error('An error occurred while retrieving transactions:', error); console.error('An error occurred while retrieving transactions:', error);
throw new Error('Could not retrieve transactions'); throw new Error('Could not retrieve transactions');
+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);
}
@@ -4,6 +4,8 @@ import logger from '$lib/utils/logger/logger';
import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin'; import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin';
import { requestAccountDeletion } from '$lib/utils/supabase/admin'; import { requestAccountDeletion } from '$lib/utils/supabase/admin';
const transactionsPerPage = 5;
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession(); const { session } = await safeGetSession();
@@ -19,9 +21,21 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession
const subscriptions = await getUserSubscriptions(session.user.id); const subscriptions = await getUserSubscriptions(session.user.id);
const transactions = await getUserTransactions(session.user.id); const transactionsData = await getUserTransactions(session.user.id, transactionsPerPage);
return { session, user, subscriptions, transactions }; 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 = { export const actions: Actions = {
@@ -78,5 +92,43 @@ export const actions: Actions = {
return { return {
name 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' });
}
} }
}; };
@@ -5,8 +5,8 @@
export let data; export let data;
export let form; export let form;
let { session, user, subscriptions, transactions } = data; let { session, user, subscriptions, transactions, firstTransactionId, hasMoreThanOnePage } = data;
$: ({ session, supabase, user } = data); $: ({ session, user, transactions, firstTransactionId, hasMoreThanOnePage } = data);
let profileForm: HTMLFormElement; let profileForm: HTMLFormElement;
let loading = false; let loading = false;
@@ -39,6 +39,22 @@
loading = false; 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> </script>
<div <div
@@ -123,11 +139,18 @@
{/if} {/if}
</section> </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 --> <!-- Billing History -->
<section class="space-y-4"> <section class="space-y-4">
<h2 class="text-xl font-semibold">Billing History</h2> <h2 class="text-xl font-semibold">Billing History</h2>
<ul class="space-y-3">
{#if transactions} {#if transactions && transactions.length > 0}
<ul class="space-y-3">
{#each transactions as transaction} {#each transactions as transaction}
<li class="flex justify-between p-4 border border-gray-200 rounded-md"> <li class="flex justify-between p-4 border border-gray-200 rounded-md">
<div> <div>
@@ -146,8 +169,58 @@
{/if} {/if}
</li> </li>
{/each} {/each}
{/if} </ul>
</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> </section>
</div> </div>
@@ -183,12 +256,12 @@
</p> </p>
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a> <a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
</section> </section>
</div>
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}> <form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
<div> <div>
<button class="button block" disabled={loading}>Delete My Account</button> <button class="btn btn-error w-full" disabled={loading}>Delete My Account</button>
</div> </div>
</form> </form>
</div>
</div> </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>
+5 -176
View File
@@ -1,34 +1,16 @@
<script lang="ts"> <script lang="ts">
import '../app.postcss'; import '../app.postcss';
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import { isBrowser } from '@supabase/ssr'; import { isBrowser } from '@supabase/ssr';
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
import { general } from '$lib/stores/generalStore.js';
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import SignOut from '$lib/components/SignOut.svelte';
import CookieConsent from '$lib/components/CookieConsent.svelte'; import CookieConsent from '$lib/components/CookieConsent.svelte';
import NavLinks from '$lib/components/NavLinks.svelte'; import Nav from '$lib/components/navigation/Nav.svelte';
import MagicLink from '$lib/components/MagicLink.svelte'; import Toast from '$lib/components/shared/Toast.svelte';
export let data; export let data;
let { supabase, session } = data; let { supabase, session } = data;
$: ({ supabase, session } = data); $: ({ supabase, session } = data);
let localBasket: Basket;
const unsubscribeToBasket = basket.subscribe((value) => {
localBasket = value;
});
let localGeneral: any;
const unsubscribeToGeneralStore = general.subscribe((value) => {
localGeneral = value;
});
onDestroy(() => {
unsubscribeToBasket();
unsubscribeToGeneralStore();
});
let cookiesAccepted = false; let cookiesAccepted = false;
onMount(() => { onMount(() => {
@@ -47,12 +29,6 @@
return () => data.subscription.unsubscribe(); return () => data.subscription.unsubscribe();
}); });
let isMobileMenuOpen = false;
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
</script> </script>
<svelte:head> <svelte:head>
@@ -69,109 +45,7 @@
</svelte:head> </svelte:head>
<header> <header>
<nav class="bg-primary text-primary-content p-4"> <Nav {session} {supabase} />
<div class="container mx-auto flex justify-between items-center">
<a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
<!-- Hamburger Menu Button (Mobile) -->
<button on:click={toggleMobileMenu} class="block md:hidden text-white focus:outline-none">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
></path>
</svg>
</button>
<!-- Navbar Links (hidden on mobile, visible on larger screens) -->
<div class="hidden md:flex space-x-4">
<ul class="flex flex-col md:flex-row md:space-x-4">
<NavLinks isMobile={false} />
</ul>
</div>
<!-- Right Side: Basket & User Account -->
<div class="flex items-center space-x-4">
<!-- Basket/Checkout Icon -->
<a href="/checkout" class="btn btn-ghost btn-circle">
<div class="indicator">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
</div>
</a>
<!-- User Account Dropdown -->
<details class="dropdown dropdown-end">
<summary tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
{#if session?.user}
<div class="user-circle text-primary-content border-primary-content">
{session?.user.email[0].toUpperCase()}
</div>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
</summary>
{#if session?.user}
<ul
tabindex="-1"
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
>
<li><a href="/account">Account</a></li>
<li><a href="/settings">Settings</a></li>
<li><SignOut {supabase} /></li>
</ul>
{:else}
<div
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
>
<MagicLink {supabase} />
</div>
{/if}
</details>
</div>
</div>
<!-- Mobile Menu -->
<div
class="bg-base-100 text-base-content shadow rounded-box p-4 transition duration-300 ease-in-out md:hidden"
class:block={isMobileMenuOpen}
class:hidden={!isMobileMenuOpen}
>
<ul class="flex flex-col space-y-4">
<NavLinks isMobile={true} />
</ul>
</div>
</nav>
</header> </header>
<main> <main>
@@ -241,52 +115,7 @@
</nav> </nav>
</footer> </footer>
<div <Toast />
id={localGeneral.toastType == 'success' ? 'success-toast' : 'error-toast'}
role="alert"
class="alert {localGeneral.toastType == 'success'
? 'alert-success'
: 'alert-error'} fixed top-20 right-3 max-w-52 text-white {localGeneral.hideToast
? 'hidden'
: ''}"
>
{#if localGeneral.toastType == 'success'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
{:else}
<svg
class="stroke-current shrink-0 h-6 w-6"
viewBox="0 0 32 32"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
><g
><g id="Error_1_"
><g id="Error">
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
<path
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
id="Exclamatory_x5F_Sign"
style="fill:#D72828;"
/>
</g>
</g></g
></svg
>
{/if}
<span>{localGeneral.toastMessage == '' ? 'Added to basket' : localGeneral.toastMessage}</span>
</div>
<style scoped lang="postcss"> <style scoped lang="postcss">
.user-circle { .user-circle {
+3 -3
View File
@@ -1,11 +1,11 @@
<script src="https://js.stripe.com/v3/" lang="ts"> <script src="https://js.stripe.com/v3/" lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { basket, type Basket, type Item } from '$lib/stores/basket.js'; import { basket, type Basket } from '$lib/stores/basket.js';
import BasketItem from '$lib/components/checkout/BasketItem.svelte'; import BasketItem from '$lib/components/checkout/BasketItem.svelte';
export let data; export let data;
let { supabase, session, url } = data; let { supabase, session } = data;
$: ({ supabase, session, url } = data); $: ({ supabase, session } = data);
let localBasket: Basket; let localBasket: Basket;
const unsubscribe = basket.subscribe((value) => { const unsubscribe = basket.subscribe((value) => {
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { basket, type Basket, type Item } from '$lib/stores/basket.js'; import { basket, type Basket } from '$lib/stores/basket.js';
import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte'; import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte';
import { formatCurrency } from '$lib/utils/currency'; import { formatCurrency } from '$lib/utils/currency';
import type { PageData } from './$types'; import type { PageData } from './$types';
+3 -11
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { basket, type Basket, type Item } from '$lib/stores/basket.js'; import { basket, type Basket } from '$lib/stores/basket.js';
import { general } from '$lib/stores/generalStore.js'; import { scheduleToast } from '$lib/stores/toastStore.js';
/** @type {import('./$types').PageData} */ /** @type {import('./$types').PageData} */
export let data; export let data;
@@ -40,15 +40,7 @@
} }
}); });
general.update((value) => { scheduleToast('Added to basket', 'success', 5000);
return { ...value, hideToast: false };
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true };
});
}, 5000);
} }
</script> </script>
@@ -0,0 +1,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;
$$;