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
+69 -4
View File
@@ -29,10 +29,19 @@ type GiftCard struct {
RedeemedBy *string `json:"redeemed_by,omitempty"` RedeemedBy *string `json:"redeemed_by,omitempty"`
} }
type UserBalance struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Balance float64 `json:"balance"`
UpdatedAt time.Time `json:"updated_at"`
}
type GiftCardSummary struct { type GiftCardSummary struct {
TotalUnclaimed float64 `json:"total_unclaimed"` TotalUnclaimed float64 `json:"total_unclaimed"`
TotalUserBalances float64 `json:"total_user_balances"` TotalUserBalances float64 `json:"total_user_balances"`
GiftCards []GiftCard `json:"gift_cards"` GiftCards []GiftCard `json:"gift_cards"`
UserBalances []UserBalance `json:"user_balances"`
} }
type CreateGiftCardRequest struct { type CreateGiftCardRequest struct {
@@ -136,6 +145,37 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
summary.GiftCards = append(summary.GiftCards, gc) summary.GiftCards = append(summary.GiftCards, gc)
} }
summary.UserBalances = []UserBalance{}
ubRows, err := db.DB.Query(ctx, `
SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at
FROM user_giftcard_balances b
JOIN users u ON b.user_id = u.id
ORDER BY b.updated_at DESC
`)
if err != nil {
log.Printf("Failed to query user balances: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer ubRows.Close()
for ubRows.Next() {
var ub UserBalance
err = ubRows.Scan(
&ub.UserID,
&ub.Name,
&ub.Email,
&ub.Balance,
&ub.UpdatedAt,
)
if err != nil {
log.Printf("Failed to scan user balance: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
summary.UserBalances = append(summary.UserBalances, ub)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summary) json.NewEncoder(w).Encode(summary)
} }
@@ -155,8 +195,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
tx, err := db.DB.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(ctx)
var gc GiftCard var gc GiftCard
err := db.DB.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES ($1, $1, $2) VALUES ($1, $1, $2)
RETURNING id, total_funds_added, amount_remaining, created_by, created_at RETURNING id, total_funds_added, amount_remaining, created_by, created_at
@@ -173,6 +221,23 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create an 'on_the_house' payment record for financial tracking
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at, updated_at)
VALUES ('full', 'on_the_house', 'completed', $1, $2, NOW(), NOW())
`, req.Amount, adminID)
if err != nil {
log.Printf("Failed to create payment record for gift card: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(gc) json.NewEncoder(w).Encode(gc)
+19
View File
@@ -587,6 +587,25 @@ func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(cards) json.NewEncoder(w).Encode(cards)
} }
func AdminGetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
service := NewPaymentService()
cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil {
log.Printf("Failed to get payment methods for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cards)
}
func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
cardID := chi.URLParam(r, "id") cardID := chi.URLParam(r, "id")
if cardID == "" || !validators.IsValidID(cardID) { if cardID == "" || !validators.IsValidID(cardID) {
+5
View File
@@ -318,6 +318,7 @@ r.Route("/admin/users", func(r chi.Router) {
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods)
}) })
r.Route("/admin/today", func(r chi.Router) { r.Route("/admin/today", func(r chi.Router) {
@@ -356,6 +357,10 @@ r.Route("/admin/users", func(r chi.Router) {
r.Post("/admin/gift-cards", payments.CreateGiftCard) r.Post("/admin/gift-cards", payments.CreateGiftCard)
r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard) r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard)
r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard) r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard)
// Admin till sale routes (POS transactions not linked to bookings)
r.Post("/admin/till/sale", payments.CreateTillSale)
r.Get("/admin/till/sale/checkout/{checkout_id}/status", payments.GetTillCheckoutStatus)
}) })
}) })
@@ -6,6 +6,7 @@
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import TillPaymentModal from '$lib/components/payments/TillPaymentModal.svelte';
interface GiftCard { interface GiftCard {
id: string; id: string;
@@ -17,18 +18,30 @@
redeemed_by?: string; redeemed_by?: string;
} }
interface UserBalance {
user_id: string;
name: string;
email: string;
balance: number;
updated_at: string;
}
interface GiftCardSummary { interface GiftCardSummary {
total_unclaimed: number; total_unclaimed: number;
total_user_balances: number; total_user_balances: number;
gift_cards: GiftCard[]; gift_cards: GiftCard[];
user_balances: UserBalance[];
} }
let summary = $state<GiftCardSummary>({ let summary = $state<GiftCardSummary>({
total_unclaimed: 0, total_unclaimed: 0,
total_user_balances: 0, total_user_balances: 0,
gift_cards: [] gift_cards: [],
user_balances: []
}); });
let activeSection = $state<'cards' | 'balances'>('cards');
let loading = $state(true); let loading = $state(true);
let showGenerateModal = $state(false); let showGenerateModal = $state(false);
let showTopUpModal = $state(false); let showTopUpModal = $state(false);
@@ -72,6 +85,18 @@
let isTopUpValid = $derived(topUpAmount && !topUpError); let isTopUpValid = $derived(topUpAmount && !topUpError);
let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError); let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError);
// Choice flow state
let generateStep = $state<'choice' | 'amount'>('choice');
let generateMode = $state<'giveaway' | 'purchase'>('giveaway');
let topUpStep = $state<'choice' | 'amount'>('choice');
let topUpMode = $state<'giveaway' | 'purchase'>('giveaway');
// TillPayment state
let showTillPayment = $state(false);
let tillAmount = $state(0);
let tillAction = $state<'create' | 'topup'>('create');
let tillGiftCardId = $state<string | undefined>(undefined);
async function fetchGiftCards() { async function fetchGiftCards() {
loading = true; loading = true;
try { try {
@@ -92,6 +117,14 @@
} }
} }
function goToGeneratePayment() {
if (!isGenerateValid) return;
tillAmount = Number(generateAmount);
tillAction = 'create';
tillGiftCardId = undefined;
showTillPayment = true;
}
async function generateGiftCard() { async function generateGiftCard() {
if (!isGenerateValid) return; if (!isGenerateValid) return;
creating = true; creating = true;
@@ -108,7 +141,7 @@
const data = await res.json(); const data = await res.json();
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`); toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
showGenerateModal = false; showGenerateModal = false;
generateAmount = ''; resetGenerateModal();
await fetchGiftCards(); await fetchGiftCards();
} else { } else {
const errText = await res.text(); const errText = await res.text();
@@ -121,6 +154,14 @@
} }
} }
function goToTopUpPayment() {
if (!isTopUpValid) return;
tillAmount = Number(topUpAmount);
tillAction = 'topup';
tillGiftCardId = selectedCardId ?? undefined;
showTillPayment = true;
}
async function topUpCard() { async function topUpCard() {
if (!isTopUpValid || !selectedCardId) return; if (!isTopUpValid || !selectedCardId) return;
toppingUp = true; toppingUp = true;
@@ -136,7 +177,7 @@
if (res.ok) { if (res.ok) {
toast.success('Gift card topped up successfully'); toast.success('Gift card topped up successfully');
showTopUpModal = false; showTopUpModal = false;
topUpAmount = ''; resetTopUpModal();
await fetchGiftCards(); await fetchGiftCards();
} else { } else {
const errText = await res.text(); const errText = await res.text();
@@ -181,6 +222,77 @@
} }
} }
function resetGenerateModal() {
generateStep = 'choice';
generateMode = 'giveaway';
generateAmount = '';
}
function resetTopUpModal() {
topUpStep = 'choice';
topUpMode = 'giveaway';
topUpAmount = '';
}
async function handleTillPaymentComplete(result: { id: string; total_amount: number; payment_method: string; status: string }) {
if (tillAction === 'create') {
if (!isGenerateValid) return;
creating = true;
try {
const res = await fetch('/api/admin/gift-cards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ amount: Number(generateAmount) })
});
if (res.ok) {
const data = await res.json();
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
showTillPayment = false;
showGenerateModal = false;
resetGenerateModal();
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to generate gift card');
}
} catch (err) {
toast.error('Network error generating gift card');
} finally {
creating = false;
}
} else {
if (!isTopUpValid || !selectedCardId) return;
toppingUp = true;
try {
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/topup`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ amount: Number(topUpAmount) })
});
if (res.ok) {
toast.success('Gift card topped up successfully');
showTillPayment = false;
showTopUpModal = false;
resetTopUpModal();
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to top up gift card');
}
} catch (err) {
toast.error('Network error topping up gift card');
} finally {
toppingUp = false;
}
}
}
function handleCodeInput(e: Event) { function handleCodeInput(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
@@ -209,6 +321,72 @@
}); });
} }
// =============== Sorting ===============
type SortKey = 'code' | 'added' | 'remaining' | 'created' | 'status' | 'name' | 'email' | 'balance' | 'updated';
let cardSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'created', dir: 'desc' });
let balanceSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'updated', dir: 'desc' });
function toggleCardSort(key: SortKey) {
if (cardSort.key === key) {
cardSort.dir = cardSort.dir === 'asc' ? 'desc' : 'asc';
} else {
cardSort.key = key;
cardSort.dir = 'asc';
}
}
function toggleBalanceSort(key: SortKey) {
if (balanceSort.key === key) {
balanceSort.dir = balanceSort.dir === 'asc' ? 'desc' : 'asc';
} else {
balanceSort.key = key;
balanceSort.dir = 'asc';
}
}
let sortedCards = $derived.by(() => {
const cards = [...summary.gift_cards];
const { key, dir } = cardSort;
const mul = dir === 'asc' ? 1 : -1;
cards.sort((a, b) => {
switch (key) {
case 'code': return a.id.localeCompare(b.id) * mul;
case 'added': return (a.total_funds_added - b.total_funds_added) * mul;
case 'remaining': return (a.amount_remaining - b.amount_remaining) * mul;
case 'created': return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * mul;
case 'status': {
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
const bVal = b.redeemed_by ? 2 : b.amount_remaining === 0 ? 1 : 0;
return (aVal - bVal) * mul;
}
default: return 0;
}
});
return cards;
});
let sortedBalances = $derived.by(() => {
const bals = [...summary.user_balances];
const { key, dir } = balanceSort;
const mul = dir === 'asc' ? 1 : -1;
bals.sort((a, b) => {
switch (key) {
case 'name': return a.name.localeCompare(b.name) * mul;
case 'email': return a.email.localeCompare(b.email) * mul;
case 'balance': return (a.balance - b.balance) * mul;
case 'updated': return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * mul;
default: return 0;
}
});
return bals;
});
function sortArrow(key: SortKey, state: typeof cardSort): string {
if (state.key !== key) return '';
return state.dir === 'asc' ? ' \u25B2' : ' \u25BC';
}
$effect(() => { $effect(() => {
if (authStore.currentToken) { if (authStore.currentToken) {
fetchGiftCards(); fetchGiftCards();
@@ -225,18 +403,7 @@
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred. Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
</Card.Description> </Card.Description>
</div> </div>
<Button onclick={() => showGenerateModal = true} class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"> <Button onclick={() => showGenerateModal = true}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Generate Gift Card Generate Gift Card
</Button> </Button>
</div> </div>
@@ -244,194 +411,312 @@
<Card.Content class="space-y-6"> <Card.Content class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
<div class="rounded-lg border border-fuchsia-100 bg-fuchsia-50 p-4"> <div class="rounded-xl border bg-card p-4">
<div class="text-xs font-semibold uppercase tracking-wider text-fuchsia-600">Total Unclaimed</div> <div class="text-xs text-gray-400">Total Unclaimed</div>
<div class="mt-1 text-2xl font-bold text-fuchsia-950"> <div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_unclaimed)} {loading ? '...' : formatCurrency(summary.total_unclaimed)}
</div> </div>
</div> </div>
<div class="rounded-lg border border-pink-100 bg-pink-50 p-4"> <div class="rounded-xl border bg-card p-4">
<div class="text-xs font-semibold uppercase tracking-wider text-pink-600">User Account Balances</div> <div class="text-xs text-gray-400">User Account Balances</div>
<div class="mt-1 text-2xl font-bold text-pink-950"> <div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_user_balances)} {loading ? '...' : formatCurrency(summary.total_user_balances)}
</div> </div>
</div> </div>
<div class="rounded-lg border border-purple-100 bg-purple-50 p-4 sm:col-span-2 md:col-span-1"> <div class="rounded-xl border bg-card p-4 sm:col-span-2 md:col-span-1">
<div class="text-xs font-semibold uppercase tracking-wider text-purple-600">Combined Liability</div> <div class="text-xs text-gray-400">Combined Liability</div>
<div class="mt-1 text-2xl font-bold text-purple-950"> <div class="mt-1 text-2xl font-bold text-card-foreground">
{loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)} {loading ? '...' : formatCurrency(summary.total_unclaimed + summary.total_user_balances)}
</div> </div>
</div> </div>
</div> </div>
<div class="hidden w-full overflow-x-auto md:block"> <!-- Tabs -->
<table class="w-full table-auto border-collapse text-sm"> <div class="flex border-b border-gray-200">
<thead> <button
<tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider"> type="button"
<th class="py-3 font-medium">Card Code</th> class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'cards'
<th class="py-3 font-medium text-right">Total Added</th> ? 'border-primary text-primary font-semibold'
<th class="py-3 font-medium text-right">Remaining</th> : 'border-transparent text-muted-foreground hover:text-foreground'}"
<th class="py-3 font-medium">Created On</th> onclick={() => activeSection = 'cards'}
<th class="py-3 font-medium">Status</th> >
<th class="py-3 text-center font-medium">Actions</th> Physical Gift Cards ({summary.gift_cards.length})
</tr> </button>
</thead> <button
<tbody> type="button"
{#if loading} class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'balances'
{#each Array(3) as _, i (i)} ? 'border-primary text-primary font-semibold'
<tr class="border-b"> : 'border-transparent text-muted-foreground hover:text-foreground'}"
<td class="py-3"><Skeleton class="h-4 w-32" /></td> onclick={() => activeSection = 'balances'}
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td> >
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td> Customer Account Balances ({summary.user_balances.length})
<td class="py-3"><Skeleton class="h-4 w-24" /></td> </button>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
</tr>
{/each}
{:else if summary.gift_cards.length === 0}
<tr>
<td colspan="6" class="py-8 text-center text-gray-500">
No gift cards generated yet. Click "Generate Gift Card" to create one.
</td>
</tr>
{:else}
{#each summary.gift_cards as gc (gc.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
<td class="py-3 text-right font-medium text-gray-600">{formatCurrency(gc.total_funds_added)}</td>
<td class="py-3 text-right font-semibold {gc.amount_remaining > 0 ? 'text-fuchsia-700' : 'text-gray-400'}">
{formatCurrency(gc.amount_remaining)}
</td>
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
<td class="py-3">
{#if gc.redeemed_by}
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
Claimed
</span>
{:else if gc.amount_remaining === 0}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
Spent
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2.5 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
Active
</span>
{/if}
</td>
<td class="py-3 text-center">
<div class="flex items-center justify-center gap-2">
{#if !gc.redeemed_by && gc.amount_remaining > 0}
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTopUpModal = true;
}}
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
>
Top Up
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTransferModal = true;
}}
class="border-pink-200 text-pink-700 hover:bg-pink-50"
>
Transfer
</Button>
{:else}
<span class="text-xs text-gray-400 italic">No actions available</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div> </div>
<!-- Mobile view --> {#if activeSection === 'cards'}
<div class="grid gap-4 md:hidden"> <div class="hidden w-full overflow-x-auto md:block">
{#if loading} <table class="w-full table-auto border-collapse text-sm">
{#each Array(2) as _, i (i)} <thead>
<div class="rounded-lg border p-4 space-y-3"> <tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider">
<Skeleton class="h-4 w-32" /> <th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('code')}>
<Skeleton class="h-4 w-full" /> Card Code{sortArrow('code', cardSort)}
<Skeleton class="h-4 w-24" /> </th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('added')}>
Total Added{sortArrow('added', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('remaining')}>
Remaining{sortArrow('remaining', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('created')}>
Created On{sortArrow('created', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('status')}>
Status{sortArrow('status', cardSort)}
</th>
<th class="py-3 font-medium text-center">Actions</th>
</tr>
</thead>
<tbody>
{#if loading}
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
</tr>
{/each}
{:else if summary.gift_cards.length === 0}
<tr>
<td colspan="6" class="py-8 text-center text-gray-500">
No gift cards generated yet. Click "Generate Gift Card" to create one.
</td>
</tr>
{:else}
{#each sortedCards as gc (gc.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
<td class="py-3 font-medium text-gray-600">{formatCurrency(gc.total_funds_added)}</td>
<td class="py-3 font-semibold text-card-foreground">{formatCurrency(gc.amount_remaining)}</td>
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
<td class="py-3">
{#if gc.redeemed_by}
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
Claimed
</span>
{:else if gc.amount_remaining === 0}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
Spent
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
Active
</span>
{/if}
</td>
<td class="py-3 text-center">
<div class="flex items-center justify-center gap-2">
{#if !gc.redeemed_by && gc.amount_remaining > 0}
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTopUpModal = true;
}}
>
Top Up
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTransferModal = true;
}}
>
Transfer
</Button>
{:else}
<span class="text-xs text-gray-400 italic">No actions available</span>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile view - Cards -->
<div class="grid gap-4 md:hidden">
{#if loading}
{#each Array(2) as _, i (i)}
<div class="rounded-lg border p-4 space-y-3">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div>
{/each}
{:else if summary.gift_cards.length === 0}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
No gift cards generated yet.
</div> </div>
{/each} {:else}
{:else if summary.gift_cards.length === 0} {#each sortedCards as gc (gc.id)}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500"> <div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
No gift cards generated yet. <div class="flex items-center justify-between">
</div> <span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
{:else} {#if gc.redeemed_by}
{#each summary.gift_cards as gc (gc.id)} <span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200">
<div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50"> Claimed
<div class="flex items-center justify-between"> </span>
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span> {:else if gc.amount_remaining === 0}
{#if gc.redeemed_by} <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200"> Spent
Claimed </span>
</span> {:else}
{:else if gc.amount_remaining === 0} <span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200"> Active
Spent </span>
</span> {/if}
{:else}
<span class="inline-flex items-center rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-700 border border-fuchsia-200">
Active
</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
<div>
<span class="text-gray-400">Total Added:</span>
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
</div> </div>
<div> <div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
<span class="text-gray-400">Remaining:</span> <div>
<span class="font-bold text-fuchsia-800 ml-1">{formatCurrency(gc.amount_remaining)}</span> <span class="text-gray-400">Total Added:</span>
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
</div>
<div>
<span class="text-gray-400">Remaining:</span>
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.amount_remaining)}</span>
</div>
<div class="col-span-2">
<span class="text-gray-400">Created:</span>
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span>
</div>
</div> </div>
<div class="col-span-2"> <div class="flex justify-end gap-2 pt-1">
<span class="text-gray-400">Created:</span> {#if !gc.redeemed_by && gc.amount_remaining > 0}
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span> <Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTopUpModal = true;
}}
class="flex-1"
>
Top Up
</Button>
<Button
variant="outline"
size="sm"
onclick={() => {
selectedCardId = gc.id;
showTransferModal = true;
}}
class="flex-1"
>
Transfer
</Button>
{/if}
</div> </div>
</div> </div>
<div class="flex justify-end gap-2 pt-1"> {/each}
{#if !gc.redeemed_by && gc.amount_remaining > 0} {/if}
<Button </div>
variant="outline" {:else if activeSection === 'balances'}
size="sm" <!-- Desktop - Account Balances -->
onclick={() => { <div class="hidden w-full overflow-x-auto md:block">
selectedCardId = gc.id; <table class="w-full table-auto border-collapse text-sm">
showTopUpModal = true; <thead>
}} <tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider">
class="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50 flex-1" <th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('name')}>
> Customer{sortArrow('name', balanceSort)}
Top Up </th>
</Button> <th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('email')}>
<Button Email{sortArrow('email', balanceSort)}
variant="outline" </th>
size="sm" <th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('balance')}>
onclick={() => { Account Balance{sortArrow('balance', balanceSort)}
selectedCardId = gc.id; </th>
showTransferModal = true; <th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('updated')}>
}} Last Updated{sortArrow('updated', balanceSort)}
class="border-pink-200 text-pink-700 hover:bg-pink-50 flex-1" </th>
> </tr>
Transfer </thead>
</Button> <tbody>
{/if} {#if loading}
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-36" /></td>
<td class="py-3"><Skeleton class="h-4 w-44" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
</tr>
{/each}
{:else if summary.user_balances.length === 0}
<tr>
<td colspan="4" class="py-8 text-center text-gray-500">
No customers have redeemed gift cards yet.
</td>
</tr>
{:else}
{#each sortedBalances as ub (ub.user_id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium text-gray-900">{ub.name}</td>
<td class="py-3 text-gray-600">{ub.email}</td>
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile view - Balances -->
<div class="grid gap-4 md:hidden">
{#if loading}
{#each Array(2) as _, i (i)}
<div class="rounded-lg border p-4 space-y-3">
<Skeleton class="h-4 w-36" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div> </div>
{/each}
{:else if summary.user_balances.length === 0}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
No customers have redeemed gift cards yet.
</div> </div>
{/each} {:else}
{/if} {#each sortedBalances as ub (ub.user_id)}
</div> <div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900">{ub.name}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
<div class="col-span-2">
<span class="text-gray-400">Email:</span>
<span class="font-medium text-gray-700 ml-1">{ub.email}</span>
</div>
<div>
<span class="text-gray-400">Balance:</span>
<span class="font-bold text-primary ml-1">{formatCurrency(ub.balance)}</span>
</div>
<div>
<span class="text-gray-400">Updated:</span>
<span class="font-medium text-gray-700 ml-1">{formatDate(ub.updated_at)}</span>
</div>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -443,32 +728,65 @@
<Modal.Description>Generate a new gift card code with a starting balance.</Modal.Description> <Modal.Description>Generate a new gift card code with a starting balance.</Modal.Description>
</Modal.Header> </Modal.Header>
<div class="space-y-4 py-4"> {#if generateStep === 'choice'}
<div class="space-y-2"> <div class="space-y-3 py-4">
<label for="generate-amount" class="text-sm font-medium">Starting Amount (£)</label> <p class="text-sm text-gray-600">How would you like to issue this gift card?</p>
<Input <button
id="generate-amount" type="button"
type="text" class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
inputmode="decimal" onclick={() => { generateMode = 'giveaway'; generateStep = 'amount'; }}
placeholder="e.g. 50.00" >
bind:value={generateAmount} <div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
/> <div class="mt-1 text-sm text-muted-foreground">Free promotional card, no payment needed</div>
{#if generateError} </button>
<span class="text-xs text-red-500 font-medium">{generateError}</span> <button
{/if} type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { generateMode = 'purchase'; generateStep = 'amount'; }}
>
<div class="font-semibold text-card-foreground">Customer Purchase</div>
<div class="mt-1 text-sm text-muted-foreground">Collect payment via cash or card machine</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => showGenerateModal = false}>Cancel</Button>
</Modal.Footer>
{:else}
<div class="space-y-4 py-4">
<div class="space-y-2">
<label for="generate-amount" class="text-sm font-medium">Starting Amount (£)</label>
<Input
id="generate-amount"
type="text"
inputmode="decimal"
placeholder="e.g. 50.00"
bind:value={generateAmount}
/>
{#if generateError}
<span class="text-xs text-red-500 font-medium">{generateError}</span>
{/if}
</div>
</div> </div>
</div>
<Modal.Footer> <Modal.Footer>
<Button variant="outline" onclick={() => showGenerateModal = false}>Cancel</Button> <Button variant="ghost" onclick={() => generateStep = 'choice'}>Back</Button>
<Button {#if generateMode === 'giveaway'}
onclick={generateGiftCard} <Button
disabled={creating || !isGenerateValid} onclick={generateGiftCard}
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white" disabled={creating || !isGenerateValid}
> >
{creating ? 'Generating...' : 'Generate Card'} {creating ? 'Generating...' : 'Generate Card'}
</Button> </Button>
</Modal.Footer> {:else}
<Button
onclick={goToGeneratePayment}
disabled={!isGenerateValid}
>
Continue to Payment
</Button>
{/if}
</Modal.Footer>
{/if}
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
@@ -480,32 +798,65 @@
<Modal.Description>Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}.</Modal.Description> <Modal.Description>Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}.</Modal.Description>
</Modal.Header> </Modal.Header>
<div class="space-y-4 py-4"> {#if topUpStep === 'choice'}
<div class="space-y-2"> <div class="space-y-3 py-4">
<label for="topup-amount" class="text-sm font-medium">Amount to Add (£)</label> <p class="text-sm text-gray-600">How would you like to add funds?</p>
<Input <button
id="topup-amount" type="button"
type="text" class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
inputmode="decimal" onclick={() => { topUpMode = 'giveaway'; topUpStep = 'amount'; }}
placeholder="e.g. 20.00" >
bind:value={topUpAmount} <div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
/> <div class="mt-1 text-sm text-muted-foreground">Free top-up, no payment needed</div>
{#if topUpError} </button>
<span class="text-xs text-red-500 font-medium">{topUpError}</span> <button
{/if} type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { topUpMode = 'purchase'; topUpStep = 'amount'; }}
>
<div class="font-semibold text-card-foreground">Customer Purchase</div>
<div class="mt-1 text-sm text-muted-foreground">Collect payment via cash or card machine</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => showTopUpModal = false}>Cancel</Button>
</Modal.Footer>
{:else}
<div class="space-y-4 py-4">
<div class="space-y-2">
<label for="topup-amount" class="text-sm font-medium">Amount to Add (£)</label>
<Input
id="topup-amount"
type="text"
inputmode="decimal"
placeholder="e.g. 20.00"
bind:value={topUpAmount}
/>
{#if topUpError}
<span class="text-xs text-red-500 font-medium">{topUpError}</span>
{/if}
</div>
</div> </div>
</div>
<Modal.Footer> <Modal.Footer>
<Button variant="outline" onclick={() => showTopUpModal = false}>Cancel</Button> <Button variant="ghost" onclick={() => topUpStep = 'choice'}>Back</Button>
<Button {#if topUpMode === 'giveaway'}
onclick={topUpCard} <Button
disabled={toppingUp || !isTopUpValid} onclick={topUpCard}
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white" disabled={toppingUp || !isTopUpValid}
> >
{toppingUp ? 'Topping Up...' : 'Add Funds'} {toppingUp ? 'Topping Up...' : 'Add Funds'}
</Button> </Button>
</Modal.Footer> {:else}
<Button
onclick={goToTopUpPayment}
disabled={!isTopUpValid}
>
Continue to Payment
</Button>
{/if}
</Modal.Footer>
{/if}
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
@@ -552,10 +903,20 @@
<Button <Button
onclick={transferCard} onclick={transferCard}
disabled={transferring || !isTransferValid} disabled={transferring || !isTransferValid}
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
> >
{transferring ? 'Transferring...' : 'Transfer Balance'} {transferring ? 'Transferring...' : 'Transfer Balance'}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
{#if showTillPayment}
<TillPaymentModal
amount={tillAmount}
itemType="gift_card"
action={tillAction}
giftCardId={tillGiftCardId}
onClose={() => { showTillPayment = false; }}
onComplete={handleTillPaymentComplete}
/>
{/if}
@@ -24,6 +24,8 @@
| 'cash-confirming' | 'cash-confirming'
| 'gift-entering' | 'gift-entering'
| 'gift-confirming' | 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
| 'success' | 'success'
| 'error'; | 'error';
@@ -35,7 +37,7 @@
amount: number; amount: number;
}; };
type PaymentMethod = 'card' | 'cash' | 'giftcard' | null; type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
let status = $state<PaymentStatus>('idle'); let status = $state<PaymentStatus>('idle');
let selectedMethod = $state<PaymentMethod>(null); let selectedMethod = $state<PaymentMethod>(null);
@@ -46,6 +48,8 @@
let customerBalance = $state(0); let customerBalance = $state(0);
let loadingCustomerBalance = $state(false); let loadingCustomerBalance = $state(false);
let giftCardPaymentAmount = $state(''); 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() { async function fetchCustomerGiftCardBalance() {
if (!booking.user_id) return; 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 = { type ServiceOverride = {
price: string; price: string;
originalPrice: number; originalPrice: number;
@@ -78,6 +99,7 @@
$effect(() => { $effect(() => {
fetchCustomerGiftCardBalance(); fetchCustomerGiftCardBalance();
fetchSavedCardList();
const services = booking.services ?? []; const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {}; const overrides: Record<string, ServiceOverride> = {};
for (const s of services) { 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(() => { $effect(() => {
if (selectedMethod === 'cash') { if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2); cashAmount = totalDue.toFixed(2);
@@ -512,6 +605,9 @@
if (selectedMethod === 'giftcard') { if (selectedMethod === 'giftcard') {
giftCardId = ''; giftCardId = '';
} }
if (selectedMethod === 'savedcard') {
fetchSavedCards();
}
}); });
</script> </script>
@@ -618,7 +714,7 @@
</div> </div>
{/if} {/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 <button
type="button" type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
@@ -665,6 +761,32 @@
</svg> </svg>
Cash Cash
</button> </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 <button
type="button" type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod === class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
@@ -693,7 +815,19 @@
</button> </button>
</div> </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 <button
type="button" type="button"
class="text-sm text-gray-600 underline hover:text-gray-900" class="text-sm text-gray-600 underline hover:text-gray-900"
@@ -916,6 +1050,78 @@
></div> ></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p> <p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div> </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} {:else if status === 'error' && error}
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3"> <div class="rounded-md border border-red-200 bg-red-50 p-3">
+2
View File
@@ -114,6 +114,8 @@ export interface Booking {
deposit_paid: boolean; deposit_paid: boolean;
deposit_deadline?: string; deposit_deadline?: string;
user_id?: string;
// Joined fields // Joined fields
user?: BookingUser; user?: BookingUser;
services?: BookingService[]; services?: BookingService[];
+9
View File
@@ -95,3 +95,12 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
} }
return age; return age;
} }
/**
* Format a 12-character gift card code into display format (XXXX-XXXX-XXXX).
* Example: "a3f1c9d2b4e8" → "A3F1-C9D2-B4E8"
*/
export function formatCardCode(id: string): string {
if (id.length !== 12) return id;
return `${id.slice(0, 4)}-${id.slice(4, 8)}-${id.slice(8, 12)}`.toUpperCase();
}
+329 -274
View File
@@ -150,6 +150,9 @@
} }
} }
let cardToDelete = $state<SavedCard | null>(null);
let showDeleteCardDialog = $state(false);
async function deleteCard(card: SavedCard) { async function deleteCard(card: SavedCard) {
try { try {
const res = await fetch(`/api/user/payment-methods/${card.id}`, { const res = await fetch(`/api/user/payment-methods/${card.id}`, {
@@ -164,6 +167,8 @@
} }
} catch { } catch {
toast.error('Network error'); toast.error('Network error');
} finally {
cardToDelete = null;
} }
} }
@@ -192,23 +197,74 @@
let buyingGiftCard = $state(false); let buyingGiftCard = $state(false);
let purchaseResultCode = $state<string | null>(null); let purchaseResultCode = $state<string | null>(null);
// Derived validations for Buy Gift Card form function parseExpiryParts(value: string): { month: number; year: number } | null {
let isBuyExpiryValid = $derived( if (!/^\d{2}\/\d{2}$/.test(value)) return null;
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
// Derived validations for Add Saved Card form
let newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
let isNewCardExpiryInPast = $derived(
newCardExpiryParts !== null &&
(() => { (() => {
const [monthStr, yearStr] = buyNewCardExpiry.split('/'); const expiryDate = new SvelteDate(newCardExpiryParts.year, newCardExpiryParts.month);
const month = parseInt(monthStr, 10); return expiryDate < new SvelteDate();
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return false;
const expiryDate = new SvelteDate(year, month);
return expiryDate >= new SvelteDate();
})() })()
); );
let isNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
let addCardError = $derived(
newCardNumber.length > 0 && !isValidLuhn(newCardNumber)
? 'Invalid card number'
: isNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isNewCardExpiryInPast
? 'This card has already expired'
: newCardCVC.length > 0 && newCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
let isAddCardValid = $derived(
isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
);
// Derived validations for Buy Gift Card form
let buyNewCardExpiryParts = $derived(parseExpiryParts(buyNewCardExpiry));
let isBuyNewCardExpiryInPast = $derived(
buyNewCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(buyNewCardExpiryParts.year, buyNewCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
let isBuyNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && buyNewCardExpiryParts === null
);
let buyCardError = $derived(
buyNewCardNumber.length > 0 && !isValidLuhn(buyNewCardNumber)
? 'Invalid card number'
: isBuyNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isBuyNewCardExpiryInPast
? 'This card has already expired'
: buyNewCardCVC.length > 0 && buyNewCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
let isBuyCardValid = $derived( let isBuyCardValid = $derived(
buySelectedCard !== '' || buySelectedCard !== '' ||
(isValidLuhn(buyNewCardNumber) && (isValidLuhn(buyNewCardNumber) &&
isBuyExpiryValid && buyNewCardExpiryParts !== null &&
!isBuyNewCardExpiryInPast &&
buyNewCardCVC.length >= 3) buyNewCardCVC.length >= 3)
); );
@@ -387,6 +443,13 @@
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount); return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
} }
function formatCardCode(id: string): string {
const raw = id.toLowerCase().replace(/[^a-z0-9]/g, '');
if (raw.length <= 4) return raw.toUpperCase();
if (raw.length <= 8) return raw.slice(0, 4).toUpperCase() + '-' + raw.slice(4, 8).toUpperCase();
return raw.slice(0, 4).toUpperCase() + '-' + raw.slice(4, 8).toUpperCase() + '-' + raw.slice(8, 12).toUpperCase();
}
function isValidLuhn(cardNumber: string): boolean { function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, ''); const s = cardNumber.replace(/\D/g, '');
let sum = 0; let sum = 0;
@@ -487,7 +550,7 @@
} }
const expiryDate = new Date(year, month); const expiryDate = new Date(year, month);
if (expiryDate < new Date()) { if (expiryDate < new Date()) {
toast.error('Card has expired'); toast.error('This card has already expired');
return; return;
} }
addingCard = true; addingCard = true;
@@ -1679,267 +1742,6 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{:else if activeTab === 'cards'} {:else if activeTab === 'cards'}
<!-- Gift Card Balance & Redemption -->
<div class="grid gap-6 md:grid-cols-2 mb-6">
<!-- Redeem Gift Card -->
<Card.Root class="border-fuchsia-100 bg-white">
<Card.Header>
<Card.Title class="text-fuchsia-900 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description>Redeem a gift card directly to your account balance.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="rounded-lg border border-fuchsia-50 bg-fuchsia-50 p-4 flex justify-between items-center">
<div>
<div class="text-xs font-semibold text-fuchsia-600 uppercase tracking-wider">Your Balance</div>
<div class="mt-1 text-2xl font-bold text-fuchsia-950">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
</div>
<div class="text-3xl text-fuchsia-300">💰</div>
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700">Enter Gift Card Code</label>
<div class="flex gap-2">
<Input
id="redeem-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={giftCardCode}
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={redeemGiftCard}
disabled={redeemingGiftCard || giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
class="bg-fuchsia-600 hover:bg-fuchsia-700 text-white"
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Buy Gift Card -->
<Card.Root class="border-pink-100 bg-white">
<Card.Header>
<Card.Title class="text-pink-900 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description>Purchase a gift card online for yourself or a friend.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="rounded-lg border border-green-100 bg-green-50 p-4 space-y-3">
<div class="text-sm font-medium text-green-800 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">
Here is your gift card code:
</p>
<div class="text-center py-2 bg-white rounded border border-green-200 font-mono font-bold text-lg tracking-wider text-green-800">
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-green-600 italic">
Please save this code! It has been emailed to the recipient.
</p>
{/if}
<Button size="sm" variant="outline" onclick={() => purchaseResultCode = null} class="w-full">
Buy Another Card
</Button>
</div>
{:else}
<!-- Amount preset selector -->
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select Value</span>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount === amount
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyAmount = amount as 10 | 20 | 50}
>
{formatCurrency(amount)}
</button>
{/each}
</div>
</div>
<!-- Recipient toggle -->
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Recipient</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 {buyRecipientType === 'self'
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'self'}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'friend'
? 'border-pink-600 bg-pink-50 text-pink-900'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'friend'}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700">Friend's Email (Optional)</label>
<Input
id="recipient-email"
type="email"
placeholder="friend@example.com (blank to send to yourself)"
bind:value={buyRecipientEmail}
class="mt-1"
/>
</div>
{/if}
<!-- Payment fields -->
<div class="space-y-3 pt-2 border-t">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Payment Method</span>
{#if savedCards.length > 0}
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === card.id
? 'border-pink-600 bg-pink-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = card.id;
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium uppercase text-gray-700">
{card.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-gray-400 text-xs">
Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
</span>
</div>
</div>
{#if buySelectedCard === card.id}
<span class="text-xs font-semibold text-pink-700">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === ''
? 'border-pink-600 bg-pink-50'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = '';
}}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded border-dashed border border-gray-300 text-xs font-medium text-gray-400">
NEW
</div>
<span class="text-sm font-medium text-gray-700 animate-pulse">Use a new card</span>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-pink-700">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 bg-gray-50 p-3 rounded-lg border">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600">Card Number</label>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600">Expiry (MM/YY)</label>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600">CVC</label>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="h-8 text-xs mt-1 bg-white"
/>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500">Save card for future purchases</label>
</div>
</div>
{/if}
</div>
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || !isBuyCardValid}
class="w-full bg-pink-600 hover:bg-pink-700 text-white mt-2"
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Saved Cards --> <!-- Saved Cards -->
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
@@ -2004,6 +1806,9 @@
/> />
</div> </div>
</div> </div>
{#if addCardError}
<div class="text-xs font-semibold text-red-500 mt-1">{addCardError}</div>
{/if}
</div> </div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
@@ -2018,7 +1823,7 @@
> >
Cancel Cancel
</Button> </Button>
<Button onclick={addCard} loading={addingCard} disabled={addingCard}> <Button onclick={addCard} loading={addingCard} disabled={addingCard || !isAddCardValid}>
Add Card Add Card
</Button> </Button>
</div> </div>
@@ -2051,7 +1856,7 @@
size="sm" size="sm"
variant="ghost" variant="ghost"
class="text-red-600 hover:bg-red-50 hover:text-red-700" class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => deleteCard(card)} onclick={() => { cardToDelete = card; showDeleteCardDialog = true; }}
> >
Remove Remove
</Button> </Button>
@@ -2064,6 +1869,230 @@
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<!-- Redeem & Buy Gift Cards -->
<div class="mt-6 grid gap-6 md:grid-cols-2">
<!-- Redeem Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
Redeem Gift Card
</Card.Title>
<Card.Description>Redeem a gift card directly to your account balance.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="rounded-lg bg-accent p-4 flex justify-between items-center">
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Your Balance</div>
<div class="mt-1 text-2xl font-bold text-card-foreground">
{loadingBalance ? '...' : formatCurrency(giftCardBalance)}
</div>
</div>
</div>
<div class="space-y-2">
<label for="redeem-code" class="text-sm font-medium text-gray-700">Enter Gift Card Code</label>
<div class="flex gap-2">
<Input
id="redeem-code"
type="text"
placeholder="xxxx-xxxx-xxxx"
maxlength={14}
value={giftCardCode}
oninput={handleGiftCardInput}
class="font-mono"
/>
<Button
onclick={redeemGiftCard}
disabled={redeemingGiftCard || giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12}
>
{redeemingGiftCard ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Buy Gift Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Buy a Gift Card
</Card.Title>
<Card.Description>Purchase a gift card online for yourself or a friend.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if purchaseResultCode}
<div class="rounded-lg border border-green-100 bg-green-50 p-4 space-y-3">
<div class="text-sm font-medium text-green-800 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Purchase Successful!
</div>
{#if buyRecipientType === 'self'}
<p class="text-xs text-green-700">
Your purchase of <strong>{formatCurrency(buyAmount)}</strong> has been automatically added to your account balance!
</p>
{:else}
<p class="text-xs text-green-700">
Here is your gift card code:
</p>
<div class="text-center py-2 bg-white rounded border border-green-200 font-mono font-bold text-lg tracking-wider text-green-800">
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-green-600 italic">
Please save this code! It has been emailed to the recipient.
</p>
{/if}
<Button size="sm" variant="outline" onclick={() => purchaseResultCode = null} class="w-full">
Buy Another Card
</Button>
</div>
{:else}
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select Value</span>
<div class="grid grid-cols-3 gap-2">
{#each [10, 20, 50] as amount}
<button
type="button"
class="rounded-lg border py-2.5 text-center text-sm font-semibold transition-colors {buyAmount === amount
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyAmount = amount as 10 | 20 | 50}
>
{formatCurrency(amount)}
</button>
{/each}
</div>
</div>
<div class="space-y-2">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Recipient</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 {buyRecipientType === 'self'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'self'}
>
For Myself (Auto-Redeem)
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {buyRecipientType === 'friend'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => buyRecipientType = 'friend'}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700">Friend's Email (Optional)</label>
<Input
id="recipient-email"
type="email"
placeholder="friend@example.com (blank to send to yourself)"
bind:value={buyRecipientEmail}
class="mt-1"
/>
</div>
{/if}
<div class="space-y-3 pt-2 border-t">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Payment Method</span>
{#if savedCards.length > 0}
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { buySelectedCard = card.id; }}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium uppercase text-gray-700">
{card.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-gray-400 text-xs">Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span>
</div>
</div>
{#if buySelectedCard === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard === ''
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { buySelectedCard = ''; }}
>
<div class="flex items-center gap-3">
<div class="flex h-8 min-w-12 items-center justify-center rounded border-dashed border border-gray-300 text-xs font-medium text-gray-400">NEW</div>
<span class="text-sm font-medium text-gray-700 animate-pulse">Use a new card</span>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 bg-gray-50 p-3 rounded-lg border">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600">Card Number</label>
<Input id="buy-card-num" type="text" inputmode="numeric" placeholder="1234 5678 9012 3456" value={buyNewCardNumber} oninput={handleBuyCardNumberInput} maxlength={19} class="h-8 text-xs mt-1 bg-white" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600">Expiry (MM/YY)</label>
<Input id="buy-card-exp" type="text" inputmode="numeric" placeholder="MM/YY" value={buyNewCardExpiry} oninput={handleBuyExpiryInput} maxlength={5} class="h-8 text-xs mt-1 bg-white" />
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600">CVC</label>
<Input id="buy-card-cvc" type="text" inputmode="numeric" placeholder="123" value={buyNewCardCVC} oninput={handleBuyCvcInput} maxlength={4} class="h-8 text-xs mt-1 bg-white" />
</div>
</div>
{#if buyCardError}
<div class="text-[10px] font-semibold text-red-500 mt-1">{buyCardError}</div>
{/if}
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500">Save card for future purchases</label>
</div>
</div>
{/if}
</div>
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard || !isBuyCardValid}
class="w-full mt-2"
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
{/if}
</Card.Content>
</Card.Root>
</div>
{:else if activeTab === 'admin'} {:else if activeTab === 'admin'}
<!-- Admin Settings --> <!-- Admin Settings -->
<Card.Root> <Card.Root>
@@ -2495,6 +2524,32 @@
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
<!-- Delete Saved Card Confirmation -->
<AlertDialog.Root
bind:open={showDeleteCardDialog}
onOpenChange={(open) => { if (!open) cardToDelete = null; }}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove saved card?</AlertDialog.Title>
<AlertDialog.Description>
{#if cardToDelete}
Remove {cardToDelete.brand} card ending in {cardToDelete.last_4}?
{/if}
You can add it again later.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => cardToDelete = null}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => cardToDelete && deleteCard(cardToDelete)} class="bg-red-600 hover:bg-red-700">
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if} {/if}
<!-- User Booking Modal --> <!-- User Booking Modal -->
+7 -8
View File
@@ -12,6 +12,7 @@
import TodayStats from '$lib/components/today/TodayStats.svelte'; import TodayStats from '$lib/components/today/TodayStats.svelte';
import CallInBooking from '$lib/components/admin/CallInBooking.svelte'; import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte'; import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
import TillPurchases from '$lib/components/admin/TillPurchases.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte';
import EditBookingModal from '$lib/components/admin/EditBookingModal.svelte'; import EditBookingModal from '$lib/components/admin/EditBookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte'; import UserModal from '$lib/components/admin/UserModal.svelte';
@@ -131,17 +132,15 @@
<!-- Current/Next Appointment Card (Full Width) --> <!-- Current/Next Appointment Card (Full Width) -->
<CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} /> <CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} />
<!-- quick booking Grid --> <!-- Quick Booking + Till Purchases Grid -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3 lg:gap-6"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<!-- Left Column: Create a booking for a walk-in customer (full width on mobile, 2/3 on large) --> <div class="space-y-4">
<div class="col-span-2 sm:col-span-1 lg:col-span-2">
<WalkInBooking /> <WalkInBooking />
</div>
<!-- Right Column: Call-in / Social messaging booking (1/3 width on large screens, half on sm+) -->
<div class="col-span-2 sm:col-span-1">
<CallInBooking /> <CallInBooking />
</div> </div>
<div>
<TillPurchases />
</div>
</div> </div>
<!-- Main Content Grid --> <!-- Main Content Grid -->
+38 -1
View File
@@ -14,7 +14,7 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate'); CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate');
CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest'); CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest');
CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial'); CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial');
CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount'); CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount', 'on_the_house');
CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded'); CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded');
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show', 'no_deposit'); CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show', 'no_deposit');
CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone');
@@ -22,6 +22,7 @@ CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_co
CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years');
CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only');
CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled');
CREATE TYPE till_item_type AS ENUM ('gift_card', 'retail_product');
-- ======================================= -- =======================================
-- SHORT ID GENERATION -- SHORT ID GENERATION
@@ -63,6 +64,10 @@ CREATE OR REPLACE FUNCTION generate_refund_id() RETURNS CHAR(12) AS $$
SELECT generate_short_id('refunds'); SELECT generate_short_id('refunds');
$$ LANGUAGE sql; $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_till_sale_id() RETURNS CHAR(12) AS $$
SELECT generate_short_id('till_sales');
$$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_affiliate_payout_id() RETURNS CHAR(12) AS $$ CREATE OR REPLACE FUNCTION generate_affiliate_payout_id() RETURNS CHAR(12) AS $$
SELECT generate_short_id('affiliate_payouts'); SELECT generate_short_id('affiliate_payouts');
$$ LANGUAGE sql; $$ LANGUAGE sql;
@@ -1517,6 +1522,38 @@ CREATE TABLE refunds (
CREATE INDEX idx_refunds_payment ON refunds(payment_id); CREATE INDEX idx_refunds_payment ON refunds(payment_id);
CREATE INDEX idx_refunds_booking ON refunds(booking_id); CREATE INDEX idx_refunds_booking ON refunds(booking_id);
-- =======================================
-- TILL SALES TABLE
-- Point-of-sale transactions not linked to a booking (gift card purchases, retail products, etc.)
-- Extensible for future at-the-till products via till_item_type + item_id
-- =======================================
CREATE TABLE till_sales (
id CHAR(12) PRIMARY KEY DEFAULT generate_till_sale_id(),
item_type till_item_type NOT NULL,
item_id CHAR(12), -- FK to gift_cards, future product tables
description TEXT NOT NULL DEFAULT '',
quantity INT NOT NULL DEFAULT 1,
unit_price NUMERIC(10,2) NOT NULL,
total_amount NUMERIC(10,2) NOT NULL,
payment_method payment_method NOT NULL,
status payment_status NOT NULL DEFAULT 'pending',
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, -- customer (nullable for walk-ins)
user_saved_card_id CHAR(12) REFERENCES user_saved_cards(id) ON DELETE SET NULL,
square_payment_id TEXT,
square_checkout_id TEXT,
idempotency_key VARCHAR(64) UNIQUE,
notes TEXT,
created_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_till_sales_created_at ON till_sales(created_at);
CREATE INDEX idx_till_sales_item_type ON till_sales(item_type);
CREATE INDEX idx_till_sales_user ON till_sales(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_till_sales_square_checkout ON till_sales(square_checkout_id) WHERE square_checkout_id IS NOT NULL;
-- ======================================= -- =======================================
-- FINANCIAL AGGREGATES TABLE -- FINANCIAL AGGREGATES TABLE
-- Monthly aggregated financial statistics (no PII) -- Monthly aggregated financial statistics (no PII)