diff --git a/.env.example b/.env.example index 4ab3dbc..3959ecc 100644 --- a/.env.example +++ b/.env.example @@ -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="" \ No newline at end of file +VITE_CONTACT_EMAIL="" +# Logger service name to identify this website +VITE_LOGGER_SERVICE_NAME="example-site" \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a64cb51 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.tabSize": 4, + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/package.json b/package.json index d1aab9e..0a83afe 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/src/lib/components/CookieConsent.svelte b/src/lib/components/CookieConsent.svelte index 760cc72..f850191 100644 --- a/src/lib/components/CookieConsent.svelte +++ b/src/lib/components/CookieConsent.svelte @@ -1,5 +1,7 @@ {#if showBanner} -
+

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); + }; + }); + + +

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} 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 @@ - +