From 2a8f04e7b31dae019f3c04f546c1ad7b75d050f7 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 25 May 2026 18:04:21 +0100 Subject: [PATCH] feat: saved cards management and account cards tab Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/lib/types/booking.ts | 1 + frontend/src/routes/account/+page.svelte | 279 ++++++++++++++++++++++- 2 files changed, 279 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/types/booking.ts b/frontend/src/lib/types/booking.ts index 0639328..f22b669 100644 --- a/frontend/src/lib/types/booking.ts +++ b/frontend/src/lib/types/booking.ts @@ -88,6 +88,7 @@ export interface Payment { invoice_number?: number; status: 'pending' | 'completed' | 'failed' | 'refunded'; amount: number; + card_last4?: string; is_vat_applicable: boolean; vat_rate?: number; vat_amount?: number; diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 5a97790..38155ae 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -52,7 +52,12 @@ }); // =============== Tab State =============== - let activeTab = $state<'general' | 'history' | 'referral' | 'admin'>('general'); + let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general'); + + let canSaveCards = $derived( + authStore.currentUser?.role === 'verified_email' || + authStore.currentUser?.role === 'affiliate' + ); type Booking = { id: string; @@ -87,6 +92,121 @@ let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true }); + type SavedCard = { + id: string; + brand: string; + last_4: string; + exp_month: number; + exp_year: number; + is_default: boolean; + }; + + let savedCards = $state([]); + let loadingCards = $state(false); + + async function fetchSavedCards() { + loadingCards = true; + try { + const res = await fetch('/api/user/payment-methods', { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + savedCards = await res.json(); + } + } catch { + toast.error('Failed to load saved cards'); + } finally { + loadingCards = false; + } + } + + async function deleteCard(card: SavedCard) { + try { + const res = await fetch(`/api/user/payment-methods/${card.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + toast.success('Card removed'); + savedCards = savedCards.filter((c) => c.id !== card.id); + } else { + toast.error('Failed to remove card'); + } + } catch { + toast.error('Network error'); + } + } + + let showAddCard = $state(false); + let newCardNumber = $state(''); + let newCardExpiry = $state(''); + let newCardCVC = $state(''); + let addingCard = $state(false); + + function formatCardNumber(value: string): string { + const digits = value.replace(/\D/g, '').substring(0, 16); + const groups = digits.match(/.{1,4}/g); + return groups ? groups.join(' ') : digits; + } + + function formatExpiryDate(value: string): string { + const digits = value.replace(/\D/g, '').substring(0, 4); + if (digits.length >= 3) { + return digits.substring(0, 2) + '/' + digits.substring(2); + } + return digits; + } + + async function addCard() { + const cardNum = newCardNumber.replace(/\s/g, ''); + if (cardNum.length < 13 || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || newCardCVC.length < 3) { + toast.error('Please fill in all card details correctly'); + return; + } + const [monthStr, yearStr] = newCardExpiry.split('/'); + const month = parseInt(monthStr, 10); + const year = 2000 + parseInt(yearStr, 10); + if (month < 1 || month > 12) { + toast.error('Invalid expiry month'); + return; + } + const expiryDate = new Date(year, month); + if (expiryDate < new Date()) { + toast.error('Card has expired'); + return; + } + addingCard = true; + try { + const res = await fetch('/api/user/payment-methods', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ + card_number: cardNum, + expiry: newCardExpiry, + cvc: newCardCVC + }) + }); + if (res.ok) { + toast.success('Card added'); + showAddCard = false; + newCardNumber = ''; + newCardExpiry = ''; + newCardCVC = ''; + fetchSavedCards(); + } else { + const errText = await res.text(); + toast.error(errText || 'Failed to add card'); + } + } catch { + toast.error('Network error'); + } finally { + addingCard = false; + } + } + async function fetchNotifPrefs() { try { const res = await fetch('/api/user/notification-preferences', { @@ -709,6 +829,30 @@ Referral + {#if canSaveCards} + + {/if} + + + + {:else if savedCards.length === 0} +
+

No saved cards yet

+ +
+ {:else} +
+ {#each savedCards as card (card.id)} +
+
+
+ {card.brand} +
+
+
+ **** {card.last_4} +
+
+ Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year} +
+
+
+ +
+ {/each} + +
+ {/if} + + {:else if activeTab === 'admin'} @@ -1365,6 +1620,28 @@ Referral + {#if canSaveCards} + + {/if} +