feat: saved cards management and account cards tab

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-25 18:04:21 +01:00
co-authored by Sisyphus
parent 785a6dbf6f
commit 2a8f04e7b3
2 changed files with 279 additions and 1 deletions
+1
View File
@@ -88,6 +88,7 @@ export interface Payment {
invoice_number?: number; invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded'; status: 'pending' | 'completed' | 'failed' | 'refunded';
amount: number; amount: number;
card_last4?: string;
is_vat_applicable: boolean; is_vat_applicable: boolean;
vat_rate?: number; vat_rate?: number;
vat_amount?: number; vat_amount?: number;
+278 -1
View File
@@ -52,7 +52,12 @@
}); });
// =============== Tab State =============== // =============== 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 = { type Booking = {
id: string; id: string;
@@ -87,6 +92,121 @@
let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true }); 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<SavedCard[]>([]);
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() { async function fetchNotifPrefs() {
try { try {
const res = await fetch('/api/user/notification-preferences', { const res = await fetch('/api/user/notification-preferences', {
@@ -709,6 +829,30 @@
</svg> </svg>
Referral Referral
</button> </button>
{#if canSaveCards}
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => {
activeTab = 'cards';
fetchSavedCards();
}}
>
<svg
class="mx-auto mb-1 h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button <button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'admin' 'admin'
@@ -1141,6 +1285,117 @@
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{:else if activeTab === 'cards'}
<!-- Saved Cards -->
<Card.Root>
<Card.Header>
<Card.Title>Saved Cards</Card.Title>
<Card.Description>Manage your saved payment methods</Card.Description>
</Card.Header>
<Card.Content>
{#if loadingCards}
<div class="space-y-3">
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</div>
{:else if showAddCard}
<div class="space-y-4">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Add New Card</h4>
<div class="space-y-3">
<div>
<label for="account-cardNumber" class="text-sm font-medium text-gray-700">Card Number</label>
<Input
id="account-cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={(e) => (newCardNumber = formatCardNumber((e.target as HTMLInputElement).value))}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="account-cardExpiry" class="text-sm font-medium text-gray-700">Expiry (MM/YY)</label>
<Input
id="account-cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={(e) => (newCardExpiry = formatExpiryDate((e.target as HTMLInputElement).value))}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="account-cardCVC" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="account-cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={(e) => (newCardCVC = (e.target as HTMLInputElement).value.replace(/\D/g, '').substring(0, 4))}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</div>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={() => { showAddCard = false; newCardNumber = ''; newCardExpiry = ''; newCardCVC = ''; }}>
Cancel
</Button>
<Button onclick={addCard} loading={addingCard} disabled={addingCard}>
Add Card
</Button>
</div>
</div>
{:else if savedCards.length === 0}
<div class="py-8 text-center">
<p class="text-gray-500">No saved cards yet</p>
<Button class="mt-4" onclick={() => (showAddCard = true)}>
Add a Card
</Button>
</div>
{:else}
<div class="space-y-3">
{#each savedCards as card (card.id)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium">
{card.brand}
</div>
<div>
<div class="text-sm font-medium">
**** {card.last_4}
</div>
<div class="text-xs text-gray-500">
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
</div>
</div>
</div>
<Button
size="sm"
variant="ghost"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => deleteCard(card)}
>
Remove
</Button>
</div>
{/each}
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
+ Add a Card
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
{:else if activeTab === 'admin'} {:else if activeTab === 'admin'}
<!-- Admin Settings --> <!-- Admin Settings -->
<Card.Root> <Card.Root>
@@ -1365,6 +1620,28 @@
Referral Referral
</button> </button>
{#if canSaveCards}
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'cards')}
>
<svg
class="mx-auto mb-1 h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Cards
</button>
{/if}
<button <button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab === class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'admin' 'admin'