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
File diff suppressed because it is too large Load Diff
@@ -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(() => {
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>
<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="mt-1 font-mono text-lg"
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>
@@ -6,18 +6,21 @@
import { Input } from '$lib/components/ui/input';
import { authStore } from '$lib/stores/auth.svelte';
import { formatCardCode } from '$lib/utils/format';
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
interface Props {
amount: number;
itemType: string;
action: 'create' | 'topup';
giftCardId?: string;
userId?: string;
isGuestMode?: boolean;
guestEmail?: string;
delivery?: 'account' | 'code';
onClose: () => void;
onComplete: (result: TillSaleResult) => void;
}
let { amount, itemType, action, giftCardId = undefined, onClose, onComplete }: Props = $props();
let { amount, itemType, action, giftCardId = undefined, userId = undefined, isGuestMode = false, guestEmail = undefined, delivery: deliveryProp = undefined, onClose, onComplete }: Props = $props();
type TillSaleResult = {
id: string;
@@ -31,7 +34,6 @@
type PaymentStep =
| 'customer-selection'
| 'delivery-selection'
| 'payment-selection'
| 'cash-entering'
| 'cash-confirming'
@@ -46,6 +48,30 @@
let error = $state<string | null>(null);
let result = $state<TillSaleResult | null>(null);
// Idempotency key — generated once per modal open, reused on retry
let idempotencyKey = $state<string>('');
function getIdempotencyKey(): string {
if (!idempotencyKey) {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
idempotencyKey = [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
return idempotencyKey;
}
// Selected customer info
let selectedCustomer = $state<{ id: string; name: string } | null>(null);
let isGuest = $state(false);
@@ -89,11 +115,7 @@
let searchTotal = $state(0);
// Customer Selection - Guest / Walk-in
let guestName = $state('');
let guestPhone = $state('');
let guestPhoneError = $state('');
let creatingGuest = $state(false);
let guestNameError = $state('');
// Saved Cards (member users)
let savedCardList = $state<
@@ -203,28 +225,6 @@
return { month, year };
}
// =============== Phone Validation (UK) ===============
function validateGuestPhone(phone: string): boolean {
if (!phone.trim()) {
guestPhoneError = 'Phone number is required';
return false;
}
const valid = isValidUKPhone(phone);
guestPhoneError = valid ? '' : 'Invalid UK phone number';
return valid;
}
function formatGuestPhoneInput(value: string): string {
return formatPhoneDisplay(value);
}
function handleGuestPhoneInput(e: Event) {
const target = e.target as HTMLInputElement;
guestPhone = formatGuestPhoneInput(target.value);
if (guestPhoneError) validateGuestPhone(guestPhone);
}
// =============== Formatting ===============
function formatCurrency(n: number): string {
@@ -232,6 +232,7 @@
}
function resetView() {
idempotencyKey = '';
step = 'customer-selection';
error = null;
cashAmount = '';
@@ -242,9 +243,6 @@
delivery = 'code';
userQuery = '';
users = [];
guestName = '';
guestPhone = '';
guestPhoneError = '';
currentCustomerInfo = null;
customerTab = 'current';
currentPage = 1;
@@ -262,7 +260,16 @@
// Auto-fetch current customer on mount
onMount(() => {
if (deliveryProp) delivery = deliveryProp;
fetchCurrentCustomer();
if (userId) {
selectedCustomer = { id: userId, name: '' };
isGuest = false;
delivery = deliveryProp ?? 'account';
step = 'payment-selection';
} else if (isGuestMode) {
createGuestCustomer();
}
});
// Auto-fetch saved cards when entering payment-selection for members
@@ -307,14 +314,10 @@
function selectCurrentCustomer() {
if (!currentCustomerInfo) return;
selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name };
isGuest = false;
delivery = 'account';
if (action === 'create') {
step = 'delivery-selection';
} else {
isGuest = currentCustomerInfo.email?.endsWith('@guest.invalid') || false;
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
step = 'payment-selection';
}
}
async function searchCustomers(page: number = 1) {
if (!userQuery.trim()) return;
@@ -355,8 +358,8 @@
const timestamp = Date.now();
const firstName = 'Walk-in';
const lastName = 'Guest';
const email = `till-${timestamp}@guest.invalid`;
const phone = '';
const email = guestEmail || `till-${timestamp}@guest.invalid`;
const phone = '07451234567';
const res = await fetch('/api/users/guest', {
method: 'POST',
@@ -367,7 +370,7 @@
body: JSON.stringify({
firstName,
lastName,
phone: toE164UK(guestPhone) ?? guestPhone,
phone,
email
})
});
@@ -387,16 +390,12 @@
}
}
function selectCustomer(user: { id: string; fullName: string }) {
function selectCustomer(user: { id: string; fullName: string; email?: string }) {
selectedCustomer = { id: user.id, name: user.fullName };
isGuest = false;
delivery = 'account';
if (action === 'create') {
step = 'delivery-selection';
} else {
isGuest = user.email?.endsWith('@guest.invalid') || false;
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
step = 'payment-selection';
}
}
// =============== Payment ===============
@@ -409,7 +408,8 @@
item_type: itemType,
action: action,
amount: amount,
payment_method: 'cash'
payment_method: 'cash',
idempotency_key: getIdempotencyKey()
};
if (giftCardId) body.gift_card_id = giftCardId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
@@ -444,7 +444,8 @@
item_type: itemType,
action: action,
amount: amount,
payment_method: 'card_machine'
payment_method: 'card_machine',
idempotency_key: getIdempotencyKey()
};
if (giftCardId) body.gift_card_id = giftCardId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
@@ -530,6 +531,7 @@
action: action,
amount: amount,
payment_method: 'online_square',
idempotency_key: getIdempotencyKey(),
card_number: cardNum,
card_exp_month: expMonth,
card_exp_year: expYear,
@@ -563,18 +565,6 @@
}
}
// =============== Delivery Choice ===============
function selectDeliveryAccount() {
delivery = 'account';
step = 'payment-selection';
}
function selectDeliveryCode() {
delivery = 'code';
step = 'payment-selection';
}
// =============== Saved Cards (members only) ===============
async function fetchSavedCards() {
@@ -605,6 +595,7 @@
action: action,
amount: amount,
payment_method: 'saved_card',
idempotency_key: getIdempotencyKey(),
user_saved_card_id: selectedSavedCardId
};
if (giftCardId) body.gift_card_id = giftCardId;
@@ -942,6 +933,32 @@
<span class="text-xl font-bold text-gray-900">{formatCurrency(amount)}</span>
</div>
{#if action === 'create' && !isGuest}
<div class="space-y-1.5">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block text-muted-foreground">Fulfillment Method</span>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'account'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
onclick={() => (delivery = 'account')}
>
Add to Account
</button>
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-center text-sm font-medium transition-colors {delivery === 'code'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'}"
onclick={() => (delivery = 'code')}
>
Generate Code
</button>
</div>
</div>
{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
@@ -1022,54 +1039,6 @@
{/if}
</div>
<div class="flex gap-3 pt-2">
<Button
variant="ghost"
onclick={() => {
step = 'customer-selection';
selectedCustomer = null;
}}
class="flex-1">Back</Button
>
</div>
</div>
{:else if step === 'delivery-selection'}
<Dialog.Header>
<Dialog.Title>Delivery Method</Dialog.Title>
<Dialog.Description>
How should {selectedCustomer?.name || 'the customer'} receive their gift card?
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-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</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(amount)}</span>
</div>
<div class="grid grid-cols-1 gap-3">
<button
type="button"
class="rounded-lg border border-input px-4 py-6 text-left transition-colors hover:bg-blue-50"
onclick={selectDeliveryAccount}
>
<p class="text-sm font-semibold">Add to Account</p>
<p class="mt-1 text-xs text-gray-500">
Balance is credited immediately &mdash; no code needed
</p>
</button>
<button
type="button"
class="rounded-lg border border-input px-4 py-6 text-left transition-colors hover:bg-fuchsia-50"
onclick={selectDeliveryCode}
>
<p class="text-sm font-semibold">Generate Gift Code</p>
<p class="mt-1 text-xs text-gray-500">
A 12-character code is generated &mdash; share with the customer
</p>
</button>
</div>
<div class="flex gap-3 pt-2">
<Button
variant="ghost"