feat(frontend): add former name display, referral discount type, and refunds to bookings

Update frontend types, stores, and components to support name history display and new API fields.

- Add previousFirstName/previousLastName to User type and BookingUser type
- Add referral discount_source type and refunds array to Booking type
- Create nameDisplay.ts utility for rendering '(formerly ...)' labels
- Create booking.ts utility for booking-related helpers
- Update 25+ admin, payments, today, and account components to display
  former names on booking cards, modals, appointment views, and user lists

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-20 16:59:31 +01:00
co-authored by Sisyphus
parent 1cf7083bcc
commit 1a3829b4d9
27 changed files with 597 additions and 300 deletions
@@ -7,6 +7,7 @@
import { EmailInput } from '$lib/components/ui/email-input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import { formatUserName } from '$lib/utils/nameDisplay';
interface GiftCard {
@@ -27,6 +28,7 @@
email: string;
balance: number;
updated_at: string;
/* TODO: add previousFirstName/previousLastName when backend sends them */
}
interface GiftCardSummary {
@@ -80,7 +82,7 @@
let generateAmount = $state('');
let generateUserQuery = $state('');
let generateUsers = $state<
Array<{ id: string; fullName: string; email?: string; phone?: string }>
Array<{ id: string; fullName: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null }>
>([]);
let generateLoadingUsers = $state(false);
let topUpAmount = $state('');
@@ -103,11 +105,11 @@
// Page 2: Customer selection state
let generateCustomerTab = $state<'current' | 'member' | 'guest'>('current');
let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string } | null>(null);
let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null);
let loadingCurrentCustomer = $state(false);
// Selection from Page 2
let selectedCustomer = $state<{ id: string; name: string; email?: string } | null>(null);
let selectedCustomer = $state<{ id: string; name: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null);
let isGuestSelected = $state(false);
// Page 3: Recipient email input
@@ -333,7 +335,9 @@
id: appointment.user.id,
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
email: appointment.user.email,
phone: appointment.user.phone
phone: appointment.user.phone,
previousFirstName: appointment.user.previous_first_name,
previousLastName: appointment.user.previous_last_name
};
}
}
@@ -993,28 +997,17 @@
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if loading || loadingSearch}
{#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>
<tbody class={sortedCards.length > 0 && (loading || loadingSearch) ? 'opacity-60' : ''}>
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<tr>
<td colspan="8" class="py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
</td>
</tr>
{/each}
{:else if sortedCards.length === 0}
<tr>
<td colspan="8" class="py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
</td>
</tr>
{:else}
{#each sortedCards as gc (gc.id)}
{:else if sortedCards.length > 0}
{#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"
@@ -1101,22 +1094,15 @@
<!-- Mobile view - Cards -->
<div class="grid gap-4 md:hidden">
{#if loading || loadingSearch}
{#each Array(2) as _, i (i)}
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div>
{/each}
{:else if sortedCards.length === 0}
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet.'}
</div>
{:else}
{#each sortedCards as gc (gc.id)}
{:else if sortedCards.length > 0}
<div class={loading || loadingSearch ? 'opacity-60' : ''}>
{#each sortedCards as gc (gc.id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
@@ -1204,6 +1190,7 @@
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -1279,7 +1266,7 @@
{: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 font-medium text-gray-900">{ub.name}<!-- TODO: add formerly name when previous name data is available --></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>
@@ -1308,7 +1295,7 @@
{#each sortedBalances as ub (ub.user_id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900">{ub.name}</span>
<span class="font-medium text-gray-900">{ub.name}<!-- TODO: add formerly name when previous name data is available --></span>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div class="col-span-2">
@@ -1593,14 +1580,16 @@
selectedCustomer = {
id: currentCustomerInfo.id,
name: currentCustomerInfo.name,
email: currentCustomerInfo.email
email: currentCustomerInfo.email,
previousFirstName: currentCustomerInfo.previousFirstName,
previousLastName: currentCustomerInfo.previousLastName
};
isGuestSelected = false;
}
}}
>
<div>
<div class="text-sm font-semibold">{currentCustomerInfo.name}</div>
<div class="text-sm font-semibold">{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}</div>
{#if currentCustomerInfo.email}
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
{/if}
@@ -1641,17 +1630,12 @@
</div>
<div class="max-h-[220px] overflow-y-auto rounded-md border border-gray-200">
{#if generateLoadingUsers}
<div class="space-y-2 p-2">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</div>
{:else if generateUsers.length === 0}
{#if generateUsers.length === 0 && !generateLoadingUsers}
<div class="flex items-center justify-center p-8 text-xs text-gray-500">
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
</div>
{:else}
<ul class="divide-y divide-gray-200">
{:else if generateUsers.length > 0}
<ul class="divide-y divide-gray-200 {generateLoadingUsers ? 'opacity-60' : ''}">
{#each generateUsers.slice(0, 5) as user (user.id)}
<li>
<button
@@ -1660,12 +1644,12 @@
? 'bg-fuchsia-100 font-medium'
: 'hover:bg-fuchsia-50/40'}"
onclick={() => {
selectedCustomer = { id: user.id, name: user.fullName, email: user.email };
selectedCustomer = { id: user.id, name: user.fullName, email: user.email, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName };
isGuestSelected = false;
}}
>
<div>
<div class="text-sm font-semibold">{user.fullName}</div>
<div class="text-sm font-semibold">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -1736,7 +1720,7 @@
<span class="text-xs text-gray-400 font-semibold uppercase tracking-wider block">Customer</span>
<p class="mt-0.5 font-medium text-gray-900">
{#if selectedCustomer}
{selectedCustomer.name} (Member)
{formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)} (Member)
{:else if isGuestSelected}
Walk-in Guest
{/if}
@@ -1865,22 +1849,6 @@
/>
</div>
<div class="grid grid-cols-4 gap-2">
{#each [Number(generateAmount), 10, 20, 50] as val}
<button
type="button"
class="rounded border border-gray-200 py-1.5 text-center text-xs font-semibold hover:bg-gray-50"
onclick={() => (cashAmount = val.toFixed(2))}
>
{#if val === Number(generateAmount)}
Exact
{:else}
£{val}
{/if}
</button>
{/each}
</div>
{#if Number(cashAmount) > Number(generateAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold">{formatCurrency(Number(cashAmount) - Number(generateAmount))}</span>
@@ -2141,22 +2109,6 @@
/>
</div>
<div class="grid grid-cols-4 gap-2">
{#each [Number(topUpAmount), 10, 20, 50] as val}
<button
type="button"
class="rounded border border-gray-200 py-1.5 text-center text-xs font-semibold hover:bg-gray-50"
onclick={() => (cashAmount = val.toFixed(2))}
>
{#if val === Number(topUpAmount)}
Exact
{:else}
£{val}
{/if}
</button>
{/each}
</div>
{#if Number(cashAmount) > Number(topUpAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold">{formatCurrency(Number(cashAmount) - Number(topUpAmount))}</span>