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
@@ -5,6 +5,7 @@
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
import { formatUserName } from '$lib/utils/nameDisplay';
interface Props {
openUserModal: (userId: string) => void;
@@ -17,6 +18,8 @@
fullName: string;
email?: string;
phone?: string;
previousFirstName?: string | null;
previousLastName?: string | null;
};
type UserListResponse = {
@@ -25,24 +28,32 @@
page: number;
perPage: number;
totalPages: number;
next_cursor?: string | null;
};
let userQuery = $state('');
let users = $state<UserListItem[]>([]);
let totalUsers = $state(0);
// Cursor-based pagination: cursors[i] = cursor to use when loading page i+1
// cursors[0] is always '' (empty cursor = first page)
let cursors = $state<string[]>(['']);
let currentPage = $state(1);
let totalPages = $state(1);
let nextCursor = $state<string | null>(null);
let loadingSearch = $state(false);
let initialLoad = $state(true);
async function fetchUsers(page: number = 1, search: string = '') {
async function fetchUsers(pageIdx: number = 0, search: string = '') {
loadingSearch = true;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: '4'
});
const cursor = cursors[pageIdx];
if (cursor) {
params.set('cursor', cursor);
}
if (search.trim()) {
params.append('q', search.trim());
@@ -58,10 +69,29 @@
if (response.ok) {
const data: UserListResponse = await response.json();
users = data.users;
if (!data.users || data.users.length === 0) {
users = [];
totalUsers = 0;
totalPages = 1;
currentPage = 1;
cursors = [''];
nextCursor = null;
loadingSearch = false;
initialLoad = false;
return;
}
users = data.users || [];
totalUsers = data.total;
currentPage = data.page;
totalPages = data.totalPages;
currentPage = pageIdx + 1;
nextCursor = data.next_cursor ?? null;
// Pre-store cursor for the next page so we can go forward
if (nextCursor && cursors.length <= pageIdx + 1) {
cursors = [...cursors, nextCursor];
}
} else {
const text = await response.text();
toast.error('Failed to load users: ' + text);
@@ -76,25 +106,29 @@
}
function searchUsers() {
cursors = [''];
nextCursor = null;
currentPage = 1;
fetchUsers(1, userQuery);
fetchUsers(0, userQuery);
}
function nextPage() {
if (currentPage < totalPages) {
fetchUsers(currentPage + 1, userQuery);
}
if (!nextCursor || currentPage >= totalPages) return;
// currentPage is 1-indexed; next page index = currentPage
fetchUsers(currentPage, userQuery);
}
function previousPage() {
if (currentPage > 1) {
fetchUsers(currentPage - 1, userQuery);
}
if (currentPage <= 1) return;
// currentPage is 1-indexed; previous page index = currentPage - 2
fetchUsers(currentPage - 2, userQuery);
}
// Load initial users on mount
// Load initial users on mount (guarded to run once)
$effect(() => {
fetchUsers();
if (initialLoad) {
fetchUsers(0);
}
});
</script>
@@ -142,7 +176,7 @@
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if initialLoad || loadingSearch}
{#if initialLoad}
{#each Array(3) as _, i (i)}
<div class="rounded bg-gray-50 p-2">
<Skeleton class="mb-1 h-4 w-32" />
@@ -154,17 +188,19 @@
{userQuery ? 'No users found matching your search.' : 'No users found.'}
</div>
{:else}
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{user.fullName}</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
<div class="relative {loadingSearch ? 'opacity-60' : ''}">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
</div>
</div>
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
</div>
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
</div>
{/each}
{/each}
</div>
{/if}
</div>