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:
@@ -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;
|
||||
|
||||
@@ -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<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() {
|
||||
try {
|
||||
const res = await fetch('/api/user/notification-preferences', {
|
||||
@@ -709,6 +829,30 @@
|
||||
</svg>
|
||||
Referral
|
||||
</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
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'admin'
|
||||
@@ -1141,6 +1285,117 @@
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</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'}
|
||||
<!-- Admin Settings -->
|
||||
<Card.Root>
|
||||
@@ -1365,6 +1620,28 @@
|
||||
Referral
|
||||
</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
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'admin'
|
||||
|
||||
Reference in New Issue
Block a user