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
+ 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);
+ };
+ });
+
+
+
+
+
+
+ {#if isOpen}
+
+ {/if}
+
diff --git a/src/lib/components/MagicLink.svelte b/src/lib/components/MagicLink.svelte
index 679e86f..c1df69e 100644
--- a/src/lib/components/MagicLink.svelte
+++ b/src/lib/components/MagicLink.svelte
@@ -1,20 +1,18 @@
-
-Products
-
- {#if isMobile}
- Parent
- {:else}
-
- ▼Parent
-
- {/if}
-
-
-Example Product
diff --git a/src/lib/components/SignOut.svelte b/src/lib/components/SignOut.svelte
index 5d355ae..34b65d2 100644
--- a/src/lib/components/SignOut.svelte
+++ b/src/lib/components/SignOut.svelte
@@ -45,4 +45,4 @@
}
-
+
diff --git a/src/lib/components/navigation/Nav.svelte b/src/lib/components/navigation/Nav.svelte
new file mode 100644
index 0000000..97a91d4
--- /dev/null
+++ b/src/lib/components/navigation/Nav.svelte
@@ -0,0 +1,124 @@
+
+
+
diff --git a/src/lib/components/navigation/NavLink.svelte b/src/lib/components/navigation/NavLink.svelte
new file mode 100644
index 0000000..dbcb775
--- /dev/null
+++ b/src/lib/components/navigation/NavLink.svelte
@@ -0,0 +1,19 @@
+
+
+{#if !linkItem.isSignout}
+ {linkItem.text}
+{:else if supabase && linkItem.isSignout}
+
+{/if}
diff --git a/src/lib/components/navigation/NavLinkParent.svelte b/src/lib/components/navigation/NavLinkParent.svelte
new file mode 100644
index 0000000..b7db981
--- /dev/null
+++ b/src/lib/components/navigation/NavLinkParent.svelte
@@ -0,0 +1,42 @@
+
+
+{#if !isMobile}
+
+ {linkItem.text}
+ {#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
+
+ {/each}
+
+{:else}
+
+ {#if isOpen}
+
+ {/if}
+{/if}
diff --git a/src/lib/components/navigation/NavLinks.svelte b/src/lib/components/navigation/NavLinks.svelte
new file mode 100644
index 0000000..25b75ed
--- /dev/null
+++ b/src/lib/components/navigation/NavLinks.svelte
@@ -0,0 +1,76 @@
+
+
+
+ {#each links as link (link.text.replace(' ', '-').toLowerCase())}
+ -
+ {#if link.isParent}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
diff --git a/src/lib/components/shared/Toast.svelte b/src/lib/components/shared/Toast.svelte
new file mode 100644
index 0000000..8be3f54
--- /dev/null
+++ b/src/lib/components/shared/Toast.svelte
@@ -0,0 +1,65 @@
+
+
+{#if !localToast.hideToast}
+
+ {#if localToast.toastType == 'success'}
+
+ {:else}
+
+ {/if}
+
+
{localToast.toastMessage}
+
+{/if}
diff --git a/src/lib/stores/generalStore.ts b/src/lib/stores/generalStore.ts
deleted file mode 100644
index 622bfc8..0000000
--- a/src/lib/stores/generalStore.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { writable } from 'svelte/store';
-
-export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
diff --git a/src/lib/stores/toastStore.ts b/src/lib/stores/toastStore.ts
new file mode 100644
index 0000000..82785cf
--- /dev/null
+++ b/src/lib/stores/toastStore.ts
@@ -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
({ 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));
+}
\ No newline at end of file
diff --git a/src/lib/types/Nav/NavLinkItem.ts b/src/lib/types/Nav/NavLinkItem.ts
new file mode 100644
index 0000000..a3cf412
--- /dev/null
+++ b/src/lib/types/Nav/NavLinkItem.ts
@@ -0,0 +1,8 @@
+export interface NavLinkItem {
+ text: string;
+ href?: string;
+ isParent?: boolean;
+ children?: NavLinkItem[];
+ isSignout?: boolean;
+ isUserAccount?: boolean;
+}
diff --git a/src/lib/types/Shared/Toast.ts b/src/lib/types/Shared/Toast.ts
new file mode 100644
index 0000000..d0fe6b5
--- /dev/null
+++ b/src/lib/types/Shared/Toast.ts
@@ -0,0 +1,5 @@
+export type Toast = {
+ hideToast: boolean,
+ toastMessage: string,
+ toastType: 'success' | 'error'
+}
\ No newline at end of file
diff --git a/src/lib/utils/logger/logger.ts b/src/lib/utils/logger/logger.ts
index b56c756..dee0abc 100644
--- a/src/lib/utils/logger/logger.ts
+++ b/src/lib/utils/logger/logger.ts
@@ -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(),
diff --git a/src/lib/utils/supabase/admin.ts b/src/lib/utils/supabase/admin.ts
index 62497ac..e7e7dce 100644
--- a/src/lib/utils/supabase/admin.ts
+++ b/src/lib/utils/supabase/admin.ts
@@ -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 {
// Step 1: Retrieve the customer ID associated with the user
const { data, error } = await supabaseAdmin
@@ -632,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',
@@ -648,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');
diff --git a/src/lib/utils/supabase/auth.ts b/src/lib/utils/supabase/auth.ts
new file mode 100644
index 0000000..2388038
--- /dev/null
+++ b/src/lib/utils/supabase/auth.ts
@@ -0,0 +1,6 @@
+import type { SupabaseClient } from '@supabase/supabase-js';
+
+export async function signOut(supabase: SupabaseClient, callback: () => void): Promise {
+ // TODO - #61 use the error from the response
+ const { error } = await supabase.auth.signOut().then(callback);
+}
diff --git a/src/routes/(user)/account/+page.server.ts b/src/routes/(user)/account/+page.server.ts
index f03bd02..2c131b9 100644
--- a/src/routes/(user)/account/+page.server.ts
+++ b/src/routes/(user)/account/+page.server.ts
@@ -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;
+
+ 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' });
+ }
}
};
diff --git a/src/routes/(user)/account/+page.svelte b/src/routes/(user)/account/+page.svelte
index fc80b8b..ede00e5 100644
--- a/src/routes/(user)/account/+page.svelte
+++ b/src/routes/(user)/account/+page.svelte
@@ -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;
+ };
+ };
Billing History
-
- {#if transactions}
+
+ {#if transactions && transactions.length > 0}
+
{#each transactions as transaction}
-
@@ -152,8 +169,58 @@
{/if}
{/each}
- {/if}
-
+
+ {:else}
+
No transactions found.
+ {/if}
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 7dfb5cd..90aa24c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,34 +1,16 @@
@@ -69,109 +45,7 @@
@@ -241,52 +115,7 @@
-
- {#if localGeneral.toastType == 'success'}
-
- {:else}
-
- {/if}
-
-
{localGeneral.toastMessage == '' ? 'Added to basket' : localGeneral.toastMessage}
-
+