feat(frontend): add gift card management UI and payment modal updates

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-06-11 22:08:27 +01:00
co-authored by Sisyphus
parent 1be82986a5
commit 7a459a3986
3 changed files with 1639 additions and 303 deletions
@@ -52,10 +52,11 @@
let loadingSavedCardList = $state(false);
async function fetchCustomerGiftCardBalance() {
if (!booking.user_id) return;
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
loadingCustomerBalance = true;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/giftcard-balance`, {
const res = await fetch(`/api/admin/users/${targetUserId}/giftcard-balance`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
@@ -74,10 +75,11 @@
}
async function fetchSavedCardList() {
if (!booking.user_id) return;
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
loadingSavedCardList = true;
try {
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
const res = await fetch(`/api/admin/users/${targetUserId}/payment-methods`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
@@ -98,8 +100,11 @@
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
$effect(() => {
fetchCustomerGiftCardBalance();
fetchSavedCardList();
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCardList();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
for (const s of services) {
@@ -532,7 +537,8 @@
let selectedSavedCardId = $state<string | null>(null);
async function fetchSavedCards() {
if (!booking.user_id) return;
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
loadingSavedCards = true;
savedCards = [];
selectedSavedCardId = null;
@@ -658,16 +664,16 @@
</div>
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50/40 p-4">
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold text-fuchsia-800 flex items-center gap-1.5">
<svg class="h-4 w-4 text-fuchsia-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="text-sm font-semibold text-gray-800 flex items-center gap-1.5">
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
<span class="rounded-full bg-fuchsia-100 px-2 py-0.5 text-xs font-semibold text-fuchsia-700">
<span class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600">
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
</span>
</div>
@@ -675,7 +681,7 @@
{#each booking.discounts as d}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-500"></span>
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
<span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
@@ -686,7 +692,7 @@
{/if}
</span>
</div>
<span class="font-medium text-fuchsia-600">-{formatCurrency(d.discount_amount)}</span>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span>
</div>
{/each}
</div>
@@ -895,7 +901,7 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1 bg-green-600 hover:bg-green-700">
<Button onclick={handleCardPayment} class="flex-1">
Charge Card
</Button>
</div>
@@ -903,7 +909,7 @@
{:else if status === 'card-processing' || status === 'card-polling'}
<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"
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
@@ -952,7 +958,7 @@
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
class="flex-1"
disabled={cashAmountNum < totalDue}
>
Confirm Cash
@@ -962,7 +968,7 @@
{:else if status === 'cash-confirming'}
<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"
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
</div>
@@ -973,14 +979,14 @@
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if booking.user_id && customerBalance > 0}
{#if (booking.user_id ?? booking.user?.id) && customerBalance > 0}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Source</span>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = true}
>
@@ -989,7 +995,7 @@
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
? 'border-fuchsia-600 bg-fuchsia-50 text-fuchsia-900 font-semibold'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => useAccountBalance = false}
>
@@ -1002,14 +1008,24 @@
{#if useAccountBalance}
<div>
<label for="giftcard-amount" class="text-sm font-medium text-gray-700">Amount to pay with Balance (£)</label>
<Input
id="giftcard-amount"
type="text"
inputmode="decimal"
value={giftCardPaymentAmount}
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
class="mt-1 font-mono text-lg"
/>
<div class="mt-1 flex gap-2">
<Input
id="giftcard-amount"
type="text"
inputmode="decimal"
value={giftCardPaymentAmount}
oninput={(e) => giftCardPaymentAmount = (e.target as HTMLInputElement).value}
class="flex-1 font-mono text-lg"
/>
<Button
variant="outline"
size="sm"
onclick={() => { giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2); }}
class="shrink-0 text-xs"
>
Full Balance
</Button>
</div>
<p class="mt-1 text-xs text-gray-500">
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance can be used.
</p>
@@ -1036,7 +1052,7 @@
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
class="flex-1"
disabled={!giftCardValid}
>
Apply Gift Card
@@ -1046,7 +1062,7 @@
{:else if status === 'gift-confirming'}
<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"
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div>
@@ -1059,7 +1075,7 @@
{#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 class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"></div>
</div>
{:else if savedCards.length === 0}
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
@@ -1073,7 +1089,7 @@
<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-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => selectedSavedCardId = card.id}
>
@@ -1108,7 +1124,7 @@
<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"
class="flex-1"
disabled={!selectedSavedCardId}
>
Charge Saved Card
@@ -1118,7 +1134,7 @@
{: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"
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
</div>