Add giftcard management

This commit is contained in:
2026-06-06 14:26:33 +01:00
parent 44c9d423ec
commit 310e6aaa80
10 changed files with 1281 additions and 523 deletions
@@ -24,6 +24,8 @@
| 'cash-confirming'
| 'gift-entering'
| 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
| 'success'
| 'error';
@@ -35,7 +37,7 @@
amount: number;
};
type PaymentMethod = 'card' | 'cash' | 'giftcard' | null;
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
let status = $state<PaymentStatus>('idle');
let selectedMethod = $state<PaymentMethod>(null);
@@ -46,6 +48,8 @@
let customerBalance = $state(0);
let loadingCustomerBalance = $state(false);
let giftCardPaymentAmount = $state('');
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let loadingSavedCardList = $state(false);
async function fetchCustomerGiftCardBalance() {
if (!booking.user_id) return;
@@ -69,6 +73,23 @@
}
}
async function fetchSavedCardList() {
if (!booking.user_id) return;
loadingSavedCardList = true;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
savedCardList = await res.json();
}
} catch {
// ignore
} finally {
loadingSavedCardList = false;
}
}
type ServiceOverride = {
price: string;
originalPrice: number;
@@ -78,6 +99,7 @@
$effect(() => {
fetchCustomerGiftCardBalance();
fetchSavedCardList();
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
for (const s of services) {
@@ -504,6 +526,77 @@
}
}
// Saved cards
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
let loadingSavedCards = $state(false);
let selectedSavedCardId = $state<string | null>(null);
async function fetchSavedCards() {
if (!booking.user_id) return;
loadingSavedCards = true;
savedCards = [];
selectedSavedCardId = null;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
savedCards = await res.json();
}
} catch {
toast.error('Failed to load saved cards');
} finally {
loadingSavedCards = false;
}
}
async function handleSavedCardPayment() {
if (!selectedSavedCardId) {
toast.error('Please select a saved card');
return;
}
status = 'saved-card-processing';
error = null;
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: Math.round(totalDue * 100),
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process saved card payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to process saved card payment';
toast.error(error ?? 'Unknown error');
}
}
$effect(() => {
if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2);
@@ -512,6 +605,9 @@
if (selectedMethod === 'giftcard') {
giftCardId = '';
}
if (selectedMethod === 'savedcard') {
fetchSavedCards();
}
});
</script>
@@ -618,7 +714,7 @@
</div>
{/if}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
@@ -665,6 +761,32 @@
</svg>
Cash
</button>
{#if savedCardList.length > 0}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'savedcard';
status = 'saved-card-selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
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" />
<path d="M6 10h12" />
<path d="M6 14h6" />
</svg>
Saved Card
</button>
{/if}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
@@ -693,7 +815,19 @@
</button>
</div>
<div class="sm:hidden">
<div class="sm:hidden flex flex-wrap gap-3">
{#if savedCardList.length > 0}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
onclick={() => {
selectedMethod = 'savedcard';
status = 'saved-card-selecting';
}}
>
Pay with Saved Card
</button>
{/if}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
@@ -916,6 +1050,78 @@
></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div>
{:else if status === 'saved-card-selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if loadingSavedCards}
<div class="flex justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-fuchsia-600"></div>
</div>
{:else if savedCards.length === 0}
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
</div>
{:else}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
{#each savedCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
? 'border-fuchsia-600 bg-fuchsia-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => selectedSavedCardId = card.id}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-gray-500" 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>
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
</div>
<span class="text-xs text-gray-500">{card.card_expiry}</span>
</div>
{#if card.cardholder_name}
<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
{/if}
</button>
{/each}
</div>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={!selectedSavedCardId}
>
Charge Saved Card
</Button>
</div>
</div>
{:else if status === 'saved-card-processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
</div>
{:else if status === 'error' && error}
<div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3">