feat(frontend): redesign schedule page with grouped card layout

Replace Card component with custom card design: group bookings by month, add color-coded status bars/badges/dots, fly transitions, loading skeletons, empty state with calendar icon, and improved time/date formatting. Remove dependency on shadcn Card component.

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-15 21:27:39 +01:00
co-authored by Sisyphus
parent af73afe401
commit 7e1a2bdf9b
+271 -78
View File
@@ -5,11 +5,10 @@
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import { fly } from 'svelte/transition';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
import type { Booking, BookingService } from '$lib/types/booking';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
let bookings = $state<Booking[]>([]);
@@ -19,18 +18,15 @@
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
goto('/login', { replaceState: true });
return;
}
pageState = 'authorized';
fetchBookings();
});
@@ -45,15 +41,12 @@
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
toast.error('Failed to load bookings');
return;
}
const data = await response.json();
const now = new SvelteDate();
bookings = (data.bookings || [])
.filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
@@ -77,90 +70,290 @@
showBookingModal = true;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-gray-100 text-gray-800'
type BookingGroup = {
label: string;
key: string;
bookings: Booking[];
};
const bookingGroups = $derived.by(() => {
const groups: BookingGroup[] = [];
let currentMonth = '';
let currentGroup: BookingGroup | null = null;
for (const booking of bookings) {
const monthKey = booking.start_time.slice(0, 7);
if (monthKey !== currentMonth) {
currentMonth = monthKey;
const d = new SvelteDate(booking.start_time);
const label = d.toLocaleDateString('en-GB', {
month: 'long',
year: 'numeric'
});
currentGroup = { label, key: monthKey, bookings: [] };
groups.push(currentGroup);
}
currentGroup!.bookings.push(booking);
}
return groups;
});
function formatTime(iso: string): string {
return new SvelteDate(iso).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit'
});
}
function formatCardDate(iso: string): string {
return new SvelteDate(iso).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
});
}
function formatEndTime(iso: string, durationMinutes: number): string {
const start = new SvelteDate(iso);
const end = new SvelteDate(start.getTime() + durationMinutes * 60000);
return end.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' });
}
const totalAppointments = $derived(bookings.length);
type StatusStyle = {
bar: string;
badge: string;
dot: string;
label: string;
};
const statusConfig: Record<string, StatusStyle> = {
pending: {
bar: 'bg-amber-500',
badge: 'bg-amber-50 text-amber-700 border-amber-200',
dot: 'bg-amber-500',
label: 'Pending'
},
confirmed: {
bar: 'bg-emerald-500',
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
dot: 'bg-emerald-500',
label: 'Confirmed'
},
in_progress: {
bar: 'bg-blue-500',
badge: 'bg-blue-50 text-blue-700 border-blue-200',
dot: 'bg-blue-500',
label: 'In Progress'
},
completed: {
bar: 'bg-gray-400',
badge: 'bg-gray-50 text-gray-600 border-gray-200',
dot: 'bg-gray-400',
label: 'Completed'
},
client_cancelled: {
bar: 'bg-red-400',
badge: 'bg-red-50 text-red-700 border-red-200',
dot: 'bg-red-500',
label: 'Cancelled'
},
we_cancelled: {
bar: 'bg-red-400',
badge: 'bg-red-50 text-red-700 border-red-200',
dot: 'bg-red-500',
label: 'Cancelled'
},
no_show: {
bar: 'bg-gray-400',
badge: 'bg-gray-50 text-gray-600 border-gray-200',
dot: 'bg-gray-400',
label: 'No Show'
}
};
function getConfig(status: string): StatusStyle {
return statusConfig[status] || statusConfig.completed;
}
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-4xl p-6">
<div class="animate-pulse space-y-4">
<div class="h-8 w-48 rounded bg-gray-200"></div>
<div class="h-64 rounded bg-gray-200"></div>
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
<div class="animate-pulse space-y-6">
<div class="h-8 w-44 rounded-lg bg-gray-200"></div>
<div class="h-4 w-64 rounded bg-gray-200"></div>
<div class="space-y-4 pt-2">
{#each Array(3) as _, i}
<div key={i} class="rounded-xl border bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="h-4 w-24 rounded bg-gray-200"></div>
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
</div>
<div class="mb-3 h-4 w-48 rounded bg-gray-200"></div>
<div class="flex items-center justify-between">
<div class="h-5 w-16 rounded bg-gray-200"></div>
<div class="h-4 w-16 rounded bg-gray-100"></div>
</div>
</div>
{/each}
</div>
</div>
</div>
{:else if pageState === 'unauthorized'}
<div class="mx-auto max-w-4xl p-6 text-center">
<p>Please log in to view your schedule.</p>
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
<p class="text-gray-500">Please log in to view your schedule.</p>
</div>
{:else}
<div class="mx-auto max-w-4xl p-6">
<h1 class="mb-6 text-2xl font-bold">My Schedule</h1>
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
<div class="mb-10 text-center">
<h1 class="font-['Playfair_Display'] text-4xl font-bold text-gray-900">My Schedule</h1>
<p class="mt-2 text-sm text-gray-500 md:text-base">
{totalAppointments === 0
? 'No upcoming appointments'
: `You have ${totalAppointments} upcoming visit${totalAppointments === 1 ? '' : 's'}`}
</p>
</div>
{#if loading}
<div class="text-center">Loading...</div>
<div class="space-y-6">
{#each Array(3) as _, i}
<div key={i} class="animate-pulse rounded-xl border bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="h-4 w-24 rounded bg-gray-200"></div>
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
</div>
<div class="mb-3 h-4 w-48 rounded bg-gray-200"></div>
<div class="flex items-center justify-between">
<div class="h-5 w-16 rounded bg-gray-200"></div>
<div class="h-4 w-16 rounded bg-gray-100"></div>
</div>
</div>
{/each}
</div>
{:else if bookings.length === 0}
<Card.Root>
<Card.Header>
<Card.Title>No Upcoming Appointments</Card.Title>
<Card.Description>You don't have any upcoming appointments.</Card.Description>
</Card.Header>
<Card.Content>
<Button href={resolve('/book')}>Book an Appointment</Button>
</Card.Content>
</Card.Root>
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<svg
class="h-8 w-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900">No Upcoming Appointments</h3>
<p class="mb-6 text-sm text-gray-500">You don't have any upcoming appointments scheduled.</p>
<Button href={resolve('/book')}>Book an Appointment</Button>
</div>
{:else}
<div class="space-y-4">
{#each bookings as booking (booking.id)}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div>
<Card.Title class="text-lg">
{new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Card.Title>
<Card.Description>
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit'
})}
{#if booking.duration_minutes}
<span class="text-gray-500"> · {booking.duration_minutes} min</span>
{/if}
</Card.Description>
</div>
<span
class="rounded-full px-2 py-1 text-xs font-medium {statusColors[booking.status] ||
'bg-gray-100 text-gray-800'}"
>
{booking.status}
</span>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
{#if booking.services && booking.services.length > 0}
<p class="font-medium">
{booking.services.map((s: BookingService) => s.service_name).join(', ')}
</p>
{/if}
{#if booking.total_amount}
<p class="text-sm text-gray-500">£{booking.total_amount.toFixed(2)}</p>
{/if}
<div class="space-y-10">
{#each bookingGroups as group (group.key)}
<section>
<div class="mb-5 flex items-center gap-4">
<h2 class="text-base font-bold text-gray-700">
{group.label}
</h2>
<div class="h-px flex-1 bg-gradient-to-r from-gray-200 to-transparent"></div>
</div>
<div class="space-y-4">
{#each group.bookings as booking (booking.id)}
{@const cfg = getConfig(booking.status)}
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
<div
in:fly={{ y: 12, duration: 300, delay: 50 }}
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
>
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
<div class="flex items-center gap-2">
<svg
class="h-4 w-4 shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm text-gray-500">
{formatCardDate(booking.start_time)}
</span>
<span class="text-base font-semibold text-gray-900 md:text-lg">
{formatTime(booking.start_time)}
{formatEndTime(booking.start_time, booking.duration_minutes || 0)}
</span>
{#if booking.duration_minutes}
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
{/if}
</div>
<span
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none {cfg.badge}"
>
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
{cfg.label}
</span>
</div>
<div class="mb-3 h-px bg-gray-100 md:mb-4"></div>
<div>
{#if booking.services && booking.services.length > 0}
<p class="font-medium text-gray-900">{serviceNames}</p>
{/if}
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
{#if booking.total_amount}
<span class="text-lg font-bold text-gray-900">
£{booking.total_amount.toFixed(2)}
</span>
{:else}
<span></span>
{/if}
<button
onclick={() => openBooking(booking.id)}
class="inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
Details
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openBooking(booking.id)}>View Details</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
</section>
{/each}
</div>
{/if}