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;
|
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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user