mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-15 20:13:50 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2294c6e276 | |||
| d4f177f24b | |||
| 8569048c3f | |||
| acb400231b | |||
| 3f037bf64e | |||
| 5336d14c54 | |||
| cda0a1232a | |||
| 22940c53df | |||
| acfdb75767 | |||
| 287fb78c0d | |||
| 565d709904 | |||
| 10b5747016 | |||
| 15d2f04754 | |||
| cf5771396c | |||
| 13dd60428d | |||
| b333ec2bef | |||
| 319520e1ca | |||
| 6268eacd6c | |||
| 4cbe02b8a0 | |||
| 9c01db944b | |||
| 7f9958cb24 | |||
| 575b154ef4 | |||
| 0189b20e0c | |||
| ffde37ed15 | |||
| 5a2b10b54b | |||
| 1113d509a8 | |||
| 8fcf650069 | |||
| c25233f58e | |||
| 180325449c | |||
| 1fbfb5c3d2 | |||
| eae3ee99e8 | |||
| 87cd2b6c87 | |||
| 0ccf04fcbc | |||
| 2174336512 | |||
| fb05f0b889 | |||
| 150c882827 | |||
| 0d8bc92dee | |||
| 3b77f926e6 | |||
| 42663e7032 | |||
| 05a50a0ce0 | |||
| d62c92e527 | |||
| 93f41de185 | |||
| 61194d7132 | |||
| b3c774b84c | |||
| 5c5fc245b3 | |||
| 981917cd6f | |||
| 5de84d2ec1 | |||
| c77dc6ac47 | |||
| 7de7ccf9aa | |||
| 8540a2c50f | |||
| d49f4b2c38 | |||
| 550d9bac18 | |||
| 21fe0cdab8 | |||
| 00fb76ca4f | |||
| 4be732005e | |||
| 7c6afd169c | |||
| 08a6f6d7cc | |||
| 3a5e961d54 | |||
| 6ed1b25583 | |||
| 867f0f43f6 | |||
| a16959d7ba | |||
| d6baf11a7c | |||
| f20e856d44 | |||
| ec8adbf807 | |||
| 5b7eafc294 | |||
| 6959bcdd04 | |||
| 090224fc70 |
+3
-1
@@ -13,4 +13,6 @@ 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=""
|
||||
VITE_CONTACT_EMAIL=""
|
||||
# Logger service name to identify this website
|
||||
VITE_LOGGER_SERVICE_NAME="example-site"
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 4,
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
@@ -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 --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:
|
||||
|
||||
@@ -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 .",
|
||||
@@ -51,6 +52,8 @@
|
||||
"@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",
|
||||
"winston": "^3.15.0"
|
||||
|
||||
Generated
+37
@@ -20,6 +20,12 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.44.2
|
||||
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:
|
||||
specifier: ^15.4.0
|
||||
version: 15.12.0
|
||||
@@ -937,6 +943,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@kurkle/color@0.3.2':
|
||||
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1533,6 +1542,10 @@ packages:
|
||||
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
@@ -1670,6 +1683,9 @@ packages:
|
||||
resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
debug@4.3.7:
|
||||
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1731,6 +1747,9 @@ packages:
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff-dom@5.1.4:
|
||||
resolution: {integrity: sha512-TSEaVdVGictY1KHg7VpVw2nuM02YKC9C8/qBkGiCnkiAybVbu1zQTMj2/dnVLRO7Z62UsqzHGpXweiOj5/jaZg==}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2993,6 +3012,9 @@ packages:
|
||||
simple-concat@1.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
@@ -4530,6 +4552,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@kurkle/color@0.3.2': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -5194,6 +5218,10 @@ snapshots:
|
||||
|
||||
chalk@5.3.0: {}
|
||||
|
||||
chart.js@4.4.6:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.2
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
@@ -5337,6 +5365,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.1
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@4.3.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -5386,6 +5416,8 @@ snapshots:
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff-dom@5.1.4: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
@@ -6699,6 +6731,11 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
|
||||
@@ -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,26 +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. 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}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
export let indicator: boolean = false;
|
||||
export let posRight: boolean = false;
|
||||
|
||||
let isOpen = false;
|
||||
let dropdown: HTMLDivElement;
|
||||
|
||||
const DropdownIcon = {
|
||||
closed: '▼',
|
||||
open: '▲'
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handleClickAnywhere = ({ target }: MouseEvent) => {
|
||||
if (!isOpen || !target) return;
|
||||
const clickTarget = target as HTMLElement;
|
||||
if (!dropdown.contains(clickTarget) || clickTarget.closest('a')) {
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickAnywhere);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickAnywhere);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={dropdown} class="relative block">
|
||||
<div class="dropdown-title block w-full">
|
||||
<button class="flex w-full" on:click={() => (isOpen = !isOpen)}>
|
||||
{#if indicator}
|
||||
<span class="mr-1">{@html isOpen ? DropdownIcon.open : DropdownIcon.closed}</span>
|
||||
{/if}
|
||||
<slot name="dropdown"></slot>
|
||||
</button>
|
||||
</div>
|
||||
{#if isOpen}
|
||||
<ul
|
||||
class:right-0={posRight}
|
||||
class="absolute rounded-box min-w-52 shadow z-[1] m-0 menu p-2 bg-base-100 text-base-content block"
|
||||
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let isMobile: boolean = false;
|
||||
</script>
|
||||
|
||||
<li><a href="/products">Products</a></li>
|
||||
<li class={isMobile ? '' : 'dropdown'}>
|
||||
{#if isMobile}
|
||||
<div tabindex="0" role="button" class="">Parent</div>
|
||||
{:else}
|
||||
<details>
|
||||
<summary class="flex items-center"><span class="mr-1">▼</span>Parent </summary>
|
||||
</details>
|
||||
{/if}
|
||||
<ul
|
||||
class="{isMobile
|
||||
? ''
|
||||
: 'dropdown-content rounded-box w-52 shadow'} z-[1] menu p-2 bg-base-100 text-base-content"
|
||||
>
|
||||
<li><a href="/">Submenu 1</a></li>
|
||||
<li><a href="/">Submenu 2</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/tools/exampleproduct">Example Product</a></li>
|
||||
@@ -45,4 +45,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={signOut}>Sign Out</button>
|
||||
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import type { Session, SupabaseClient } from '@supabase/supabase-js';
|
||||
import MagicLink from '../MagicLink.svelte';
|
||||
import NavLinks from './NavLinks.svelte';
|
||||
import SignOut from '../SignOut.svelte';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Database } from '$lib/types/supabase';
|
||||
|
||||
export let session: Session | null;
|
||||
export let supabase: SupabaseClient<Database>;
|
||||
|
||||
let localBasket: Basket;
|
||||
const unsubscribeToBasket = basket.subscribe((value) => {
|
||||
localBasket = value;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeToBasket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav class="bg-primary text-primary-content p-4">
|
||||
<!-- Nav Container -->
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<!-- Hamburger Menu Button (Mobile) -->
|
||||
<div class="lg:hidden mr-4">
|
||||
<Dropdown>
|
||||
<div slot="dropdown">
|
||||
<button class="block text-white focus:outline-none" aria-label="Open navigation menu">
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h8m-8 6h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dropdown content -->
|
||||
<NavLinks isMobile {session} {supabase} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
|
||||
<div class="hidden lg:block">
|
||||
<NavLinks {session} />
|
||||
</div>
|
||||
<!-- Right Side: Basket & User Account -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Basket/Checkout Icon -->
|
||||
<a href="/checkout" class="btn btn-ghost btn-circle" aria-label="View basket">
|
||||
<div class="indicator">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
{#if localBasket.items.length > 0}
|
||||
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- User Account Dropdown -->
|
||||
<Dropdown posRight>
|
||||
<div slot="dropdown">
|
||||
<div
|
||||
class="btn btn-ghost btn-circle avatar placeholder hidden md:flex"
|
||||
aria-label="User account menu"
|
||||
>
|
||||
<div class="w-10 rounded-full">
|
||||
{#if session?.user}
|
||||
<div class="user-circle text-primary-content border-primary-content">
|
||||
<span class="text-xl"
|
||||
>{session?.user.email ? session?.user.email[0].toUpperCase() : '?'}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dropdown content -->
|
||||
{#if session?.user}
|
||||
<li><a href="/account">Account</a></li>
|
||||
<li><SignOut {supabase} /></li>
|
||||
{:else}
|
||||
<MagicLink {supabase} />
|
||||
{/if}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import SignOut from '../SignOut.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let linkItem: NavLinkItem;
|
||||
export let supabase: SupabaseClient | null = null;
|
||||
</script>
|
||||
|
||||
{#if !linkItem.isSignout}
|
||||
<a
|
||||
role="menuitem"
|
||||
href={linkItem.href}
|
||||
aria-current={linkItem.href === $page.url.pathname ? 'page' : undefined}>{linkItem.text}</a
|
||||
>
|
||||
{:else if supabase && linkItem.isSignout}
|
||||
<SignOut {supabase} />
|
||||
{/if}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import NavLink from './NavLink.svelte';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export let linkItem: NavLinkItem;
|
||||
export let isMobile: boolean;
|
||||
export let supabase: SupabaseClient | null;
|
||||
|
||||
let isOpen = false;
|
||||
</script>
|
||||
|
||||
{#if !isMobile}
|
||||
<Dropdown indicator>
|
||||
<div slot="dropdown">{linkItem.text}</div>
|
||||
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||
<li><NavLink linkItem={child} /></li>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
{:else}
|
||||
<button
|
||||
class:menu-dropdown-show={isOpen}
|
||||
class="menu-dropdown-toggle"
|
||||
on:click={() => (isOpen = !isOpen)}
|
||||
>
|
||||
{linkItem.text}
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<ul
|
||||
role="menu"
|
||||
class="menu-dropdown menu-dropdown-show"
|
||||
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||
>
|
||||
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||
<li><NavLink linkItem={child} {supabase} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import NavLinkParent from './NavLinkParent.svelte';
|
||||
import { type SupabaseClient } from '@supabase/supabase-js';
|
||||
import NavLink from './NavLink.svelte';
|
||||
|
||||
export let session: Session | null;
|
||||
export let supabase: SupabaseClient | null = null;
|
||||
export let isMobile = false;
|
||||
|
||||
const userAccountLinks: NavLinkItem[] = [];
|
||||
|
||||
if (isMobile) {
|
||||
if (session) {
|
||||
userAccountLinks.push({
|
||||
text: 'User', // TODO - #62 replace with user's name - will require a database call to get the user's name - suggest setting up a user model to store user data.
|
||||
isParent: true,
|
||||
isUserAccount: true,
|
||||
children: [
|
||||
{
|
||||
text: 'Account',
|
||||
href: '/account'
|
||||
},
|
||||
{
|
||||
text: 'Sign Out',
|
||||
isSignout: true
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
userAccountLinks.push({
|
||||
text: 'Log in',
|
||||
href: '/login'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const links: NavLinkItem[] = [
|
||||
...userAccountLinks,
|
||||
{
|
||||
text: 'Products',
|
||||
href: '/products'
|
||||
},
|
||||
{
|
||||
text: 'Parent',
|
||||
isParent: true,
|
||||
children: [
|
||||
{
|
||||
text: 'Submenu 1',
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
text: 'Submenu 2',
|
||||
href: '/'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Example Product',
|
||||
href: '/tools/exampleproduct'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<ul role="menu" class:flex={!isMobile} class:space-x-4={!isMobile}>
|
||||
{#each links as link (link.text.replace(' ', '-').toLowerCase())}
|
||||
<li class:md:hidden={link.isUserAccount}>
|
||||
{#if link.isParent}
|
||||
<NavLinkParent linkItem={link} {isMobile} {supabase} />
|
||||
{:else}
|
||||
<NavLink linkItem={link} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { toast } from '$lib/stores/toastStore.js';
|
||||
import type { Toast } from '$lib/types/Shared/Toast';
|
||||
|
||||
let localToast: Toast;
|
||||
const unsubscribeToToastStore = toast.subscribe((value: Toast) => {
|
||||
localToast = value;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeToToastStore();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !localToast.hideToast}
|
||||
<div
|
||||
id={localToast.toastType == 'success' ? 'success-toast' : 'error-toast'}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="alert {localToast.toastType == 'success'
|
||||
? 'alert-success'
|
||||
: 'alert-error'} fixed top-20 right-3 max-w-80 z-50 text-white"
|
||||
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||
>
|
||||
{#if localToast.toastType == 'success'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
{:else}
|
||||
<svg
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
viewBox="0 0 32 32"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
><g
|
||||
><g id="Error_1_"
|
||||
><g id="Error">
|
||||
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
|
||||
<path
|
||||
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
|
||||
id="Exclamatory_x5F_Sign"
|
||||
style="fill:#D72828;"
|
||||
/>
|
||||
</g>
|
||||
</g></g
|
||||
></svg
|
||||
>
|
||||
{/if}
|
||||
|
||||
<span>{localToast.toastMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Toast } from '$lib/types/Shared/Toast';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const MIN_DELAY = 3000;
|
||||
|
||||
export const toast = writable<Toast>({ hideToast: true, toastMessage: '', toastType: 'success' });
|
||||
export function scheduleToast(message: string, toastType: 'success' | 'error', delay: number = MIN_DELAY) {
|
||||
|
||||
toast.update((value) => {
|
||||
return {
|
||||
...value,
|
||||
hideToast: false,
|
||||
toastMessage: message,
|
||||
toastType: toastType
|
||||
};
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.update((value) => {
|
||||
return { ...value, hideToast: true, toastMessage: '' };
|
||||
});
|
||||
}, Math.max(delay, MIN_DELAY));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface NavLinkItem {
|
||||
text: string;
|
||||
href?: string;
|
||||
isParent?: boolean;
|
||||
children?: NavLinkItem[];
|
||||
isSignout?: boolean;
|
||||
isUserAccount?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Toast = {
|
||||
hideToast: boolean,
|
||||
toastMessage: string,
|
||||
toastType: 'success' | 'error'
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger, transports, format } from '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({
|
||||
dataset: VITE_AXIOM_DATASET,
|
||||
@@ -9,7 +13,7 @@ const axiomTransport = new AxiomTransport({
|
||||
|
||||
const logger = createLogger({
|
||||
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.errors({ stack: true }), // Captures error stack traces
|
||||
format.timestamp(),
|
||||
|
||||
@@ -451,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;
|
||||
@@ -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 {
|
||||
// Step 1: Retrieve the customer ID associated with the user
|
||||
const { data, error } = await supabaseAdmin
|
||||
@@ -600,8 +627,8 @@ const getUserTransactions = async (userId: string) => {
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error retrieving user data:', error.message);
|
||||
throw new Error('Failed to fetch user data');
|
||||
logger.error('Error retrieving user data:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = data?.stripe_customer_id;
|
||||
@@ -610,10 +637,21 @@ const getUserTransactions = async (userId: string) => {
|
||||
}
|
||||
|
||||
// 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
|
||||
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',
|
||||
@@ -626,7 +664,7 @@ const getUserTransactions = async (userId: string) => {
|
||||
receipt_url: charge.receipt_url
|
||||
}));
|
||||
|
||||
return transactions;
|
||||
return { transactions, hasNextPage };
|
||||
} catch (error) {
|
||||
console.error('An error occurred while retrieving transactions:', error);
|
||||
throw new Error('Could not retrieve transactions');
|
||||
|
||||
@@ -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 { requestAccountDeletion } from '$lib/utils/supabase/admin';
|
||||
|
||||
const transactionsPerPage = 5;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals: { supabase, 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 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 = {
|
||||
@@ -78,5 +92,43 @@ export const actions: Actions = {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -5,8 +5,8 @@
|
||||
export let data;
|
||||
export let form;
|
||||
|
||||
let { session, user, subscriptions, transactions } = data;
|
||||
$: ({ session, supabase, user } = data);
|
||||
let { session, user, subscriptions, transactions, firstTransactionId, hasMoreThanOnePage } = data;
|
||||
$: ({ session, user, transactions, firstTransactionId, hasMoreThanOnePage } = data);
|
||||
|
||||
let profileForm: HTMLFormElement;
|
||||
let loading = false;
|
||||
@@ -39,6 +39,22 @@
|
||||
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
|
||||
@@ -123,11 +139,18 @@
|
||||
{/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>
|
||||
<ul class="space-y-3">
|
||||
{#if transactions}
|
||||
|
||||
{#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>
|
||||
@@ -146,8 +169,58 @@
|
||||
{/if}
|
||||
</li>
|
||||
{/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>
|
||||
</div>
|
||||
|
||||
@@ -183,12 +256,12 @@
|
||||
</p>
|
||||
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||
<div>
|
||||
<button class="button block" disabled={loading}>Delete My Account</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||
<div>
|
||||
<button class="btn btn-error w-full" disabled={loading}>Delete My Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getUserCredits, getUserCreditTransactions } from '$lib/utils/supabase/admin';
|
||||
import logger from '$lib/utils/logger/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => {
|
||||
const { session } = await safeGetSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(303, '/');
|
||||
}
|
||||
|
||||
const userId = session.user?.id;
|
||||
if (!userId) {
|
||||
return { error: 'User not authenticated' };
|
||||
}
|
||||
|
||||
try {
|
||||
const creditsRemaining = await getUserCredits(userId).catch((error) => {
|
||||
logger.error('Failed to fetch user credits:', error);
|
||||
throw new Error('Unable to retrieve credit balance');
|
||||
});
|
||||
const transactions = await getUserCreditTransactions(userId).catch((error) => {
|
||||
logger.error('Failed to fetch credit transactions:', error);
|
||||
throw new Error('Unable to retrieve transaction history');
|
||||
});
|
||||
|
||||
const monthlyAggregates = transactions.reduce((acc, transaction) => {
|
||||
const date = new Date(transaction.created_at);
|
||||
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||
|
||||
if (!acc[month]) acc[month] = { credits_added: 0, credits_deducted: 0 };
|
||||
|
||||
if (transaction.credits_change > 0) {
|
||||
acc[month].credits_added += transaction.credits_change;
|
||||
} else {
|
||||
acc[month].credits_deducted += transaction.credits_change;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
creditsRemaining,
|
||||
transactions,
|
||||
monthlyAggregates
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Chart, { type ChartData } from 'chart.js/auto';
|
||||
import { DataTable } from 'simple-datatables';
|
||||
import 'simple-datatables/dist/style.css';
|
||||
|
||||
export let data;
|
||||
const { creditsRemaining, transactions, monthlyAggregates } = data;
|
||||
|
||||
let chartCanvas: HTMLCanvasElement | null = null;
|
||||
let creditsChart: Chart | null = null;
|
||||
let chartType: 'bar' | 'line' = 'bar';
|
||||
let chartData: ChartData | null = null;
|
||||
|
||||
const monthlyLabels = Object.keys(monthlyAggregates).reverse();
|
||||
const creditsAdded = monthlyLabels.map((month) => monthlyAggregates[month].credits_added);
|
||||
const creditsDeducted = monthlyLabels.map((month) =>
|
||||
Math.abs(monthlyAggregates[month].credits_deducted)
|
||||
);
|
||||
|
||||
const groupedTransactions = transactions.reduce((acc, transaction) => {
|
||||
const date = new Date(transaction.created_at);
|
||||
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||
if (!acc[month]) acc[month] = [];
|
||||
acc[month].push(transaction);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let cumulativeBalance = 0;
|
||||
const cumulativeBalanceData = monthlyLabels.map((month) => {
|
||||
const monthTransactions = groupedTransactions[month] || [];
|
||||
const monthCreditsChange = monthTransactions.reduce((sum, transaction) => {
|
||||
return sum + transaction.credits_change;
|
||||
}, 0);
|
||||
|
||||
cumulativeBalance += monthCreditsChange;
|
||||
|
||||
return cumulativeBalance;
|
||||
});
|
||||
function initialiseChartData() {
|
||||
if (chartType === 'bar') {
|
||||
chartData = {
|
||||
labels: monthlyLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Credits Added',
|
||||
data: creditsAdded,
|
||||
backgroundColor: 'green'
|
||||
},
|
||||
{
|
||||
label: 'Credits Deducted',
|
||||
data: creditsDeducted,
|
||||
backgroundColor: 'red'
|
||||
}
|
||||
]
|
||||
};
|
||||
} else if (chartType === 'line') {
|
||||
chartData = {
|
||||
labels: monthlyLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Cumulative Balance',
|
||||
data: cumulativeBalanceData,
|
||||
borderColor: 'blue',
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function updateChart(type: 'bar' | 'line') {
|
||||
chartType = type;
|
||||
initialiseChartData();
|
||||
if (creditsChart) {
|
||||
creditsChart.config.type = chartType;
|
||||
creditsChart.data = chartData;
|
||||
creditsChart.update();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initialiseChartData();
|
||||
if (chartCanvas && chartData) {
|
||||
creditsChart = new Chart(chartCanvas, {
|
||||
type: chartType,
|
||||
data: chartData,
|
||||
options: { responsive: true }
|
||||
});
|
||||
}
|
||||
|
||||
const dataTable = new DataTable('#transaction-history');
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col items-center p-4">
|
||||
<h1 class="text-3xl font-bold mb-4">Credit Dashboard</h1>
|
||||
<p class="text-lg">Credits Remaining: {creditsRemaining}</p>
|
||||
|
||||
<div class="flex justify-center my-4">
|
||||
<button on:click={() => updateChart('bar')} class:font-bold={chartType === 'bar'}
|
||||
>Monthly Usage</button
|
||||
>
|
||||
<button on:click={() => updateChart('line')} class:font-bold={chartType === 'line'}
|
||||
>Cumulative Balance</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-3/4 mt-6">
|
||||
<canvas bind:this={chartCanvas} class="my-8"></canvas>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold mb-2">Transaction History</h2>
|
||||
<div class="overflow-x-auto w-full md:w-3/4">
|
||||
{#if transactions.length === 0}
|
||||
<p class="text-lg">No transactions found.</p>
|
||||
{:else}
|
||||
<table id="transaction-history" class="table-auto w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="px-4 py-2">Date</th>
|
||||
<th class="px-4 py-2">Description</th>
|
||||
<th class="px-4 py-2">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each transactions as transaction}
|
||||
<tr class="border-b hover:bg-gray-100">
|
||||
<td class="px-4 py-2">{new Date(transaction.created_at).toLocaleDateString()}</td>
|
||||
<td class="px-4 py-2">{transaction.description}</td>
|
||||
<td
|
||||
class="px-4 py-2"
|
||||
class:text-green-600={transaction.credits_change > 0}
|
||||
class:text-red-600={transaction.credits_change < 0}
|
||||
>
|
||||
{transaction.credits_change > 0 ? '+' : ''}{transaction.credits_change}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
text-align: center;
|
||||
}
|
||||
table {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button {
|
||||
margin: 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.font-bold {
|
||||
font-weight: bold;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
+5
-176
@@ -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>
|
||||
@@ -241,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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user