Merge remote-tracking branch 'origin/master' into 54-add-credit-usage-dashboard-for-users

This commit is contained in:
Josh Creek
2024-11-19 20:51:04 +00:00
27 changed files with 638 additions and 252 deletions
+2
View File
@@ -14,3 +14,5 @@ VITE_BREVO_SENDER_EMAIL="noreply@example.com"
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
}
+1
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 .",
+6 -1
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;
@@ -18,7 +20,10 @@
</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"
+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/dist/module/SupabaseClient';
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(),
+19 -3
View File
@@ -612,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
@@ -632,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',
@@ -648,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);
}
+54 -2
View File
@@ -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;
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' });
}
} }
}; };
+73 -6
View File
@@ -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
@@ -132,8 +148,9 @@
<!-- 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>
@@ -152,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>
+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;
$$;