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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user