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:
@@ -5,11 +5,10 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
import type { Booking, BookingService } from '$lib/types/booking';
|
import type { Booking, BookingService } from '$lib/types/booking';
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
|
||||||
|
|
||||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||||
let bookings = $state<Booking[]>([]);
|
let bookings = $state<Booking[]>([]);
|
||||||
@@ -19,18 +18,15 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
if (authStore.isLoading) {
|
if (authStore.isLoading) {
|
||||||
pageState = 'loading';
|
pageState = 'loading';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
pageState = 'unauthorized';
|
pageState = 'unauthorized';
|
||||||
goto('/login', { replaceState: true });
|
goto('/login', { replaceState: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pageState = 'authorized';
|
pageState = 'authorized';
|
||||||
fetchBookings();
|
fetchBookings();
|
||||||
});
|
});
|
||||||
@@ -45,15 +41,12 @@
|
|||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error('Failed to load bookings');
|
toast.error('Failed to load bookings');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const now = new SvelteDate();
|
const now = new SvelteDate();
|
||||||
|
|
||||||
bookings = (data.bookings || [])
|
bookings = (data.bookings || [])
|
||||||
.filter((b: Booking) => {
|
.filter((b: Booking) => {
|
||||||
const startTime = new SvelteDate(b.start_time);
|
const startTime = new SvelteDate(b.start_time);
|
||||||
@@ -77,90 +70,290 @@
|
|||||||
showBookingModal = true;
|
showBookingModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
type BookingGroup = {
|
||||||
pending: 'bg-yellow-100 text-yellow-800',
|
label: string;
|
||||||
confirmed: 'bg-green-100 text-green-800',
|
key: string;
|
||||||
in_progress: 'bg-blue-100 text-blue-800',
|
bookings: Booking[];
|
||||||
completed: 'bg-gray-100 text-gray-800'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
{#if pageState === 'loading'}
|
{#if pageState === 'loading'}
|
||||||
<div class="mx-auto max-w-4xl p-6">
|
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||||||
<div class="animate-pulse space-y-4">
|
<div class="animate-pulse space-y-6">
|
||||||
<div class="h-8 w-48 rounded bg-gray-200"></div>
|
<div class="h-8 w-44 rounded-lg bg-gray-200"></div>
|
||||||
<div class="h-64 rounded 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>
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if pageState === 'unauthorized'}
|
{:else if pageState === 'unauthorized'}
|
||||||
<div class="mx-auto max-w-4xl p-6 text-center">
|
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
|
||||||
<p>Please log in to view your schedule.</p>
|
<p class="text-gray-500">Please log in to view your schedule.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mx-auto max-w-4xl p-6">
|
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||||||
<h1 class="mb-6 text-2xl font-bold">My Schedule</h1>
|
<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}
|
{#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}
|
{:else if bookings.length === 0}
|
||||||
<Card.Root>
|
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
|
||||||
<Card.Header>
|
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||||
<Card.Title>No Upcoming Appointments</Card.Title>
|
<svg
|
||||||
<Card.Description>You don't have any upcoming appointments.</Card.Description>
|
class="h-8 w-8 text-gray-400"
|
||||||
</Card.Header>
|
fill="none"
|
||||||
<Card.Content>
|
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>
|
<Button href={resolve('/book')}>Book an Appointment</Button>
|
||||||
</Card.Content>
|
</div>
|
||||||
</Card.Root>
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<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">
|
<div class="space-y-4">
|
||||||
{#each bookings as booking (booking.id)}
|
{#each group.bookings as booking (booking.id)}
|
||||||
<Card.Root>
|
{@const cfg = getConfig(booking.status)}
|
||||||
<Card.Header class="flex flex-row items-center justify-between pb-2">
|
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
|
||||||
<div>
|
|
||||||
<Card.Title class="text-lg">
|
<div
|
||||||
{new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
|
in:fly={{ y: 12, duration: 300, delay: 50 }}
|
||||||
weekday: 'long',
|
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
day: 'numeric',
|
>
|
||||||
month: 'long',
|
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
|
||||||
year: 'numeric'
|
|
||||||
})}
|
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
|
||||||
</Card.Title>
|
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
|
||||||
<Card.Description>
|
<div class="flex items-center gap-2">
|
||||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
<svg
|
||||||
hour: 'numeric',
|
class="h-4 w-4 shrink-0 text-gray-400"
|
||||||
minute: '2-digit'
|
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}
|
{#if booking.duration_minutes}
|
||||||
<span class="text-gray-500"> · {booking.duration_minutes} min</span>
|
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Description>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-2 py-1 text-xs font-medium {statusColors[booking.status] ||
|
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}"
|
||||||
'bg-gray-100 text-gray-800'}"
|
|
||||||
>
|
>
|
||||||
{booking.status}
|
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
|
||||||
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
</Card.Header>
|
</div>
|
||||||
<Card.Content>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="mb-3 h-px bg-gray-100 md:mb-4"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if booking.services && booking.services.length > 0}
|
{#if booking.services && booking.services.length > 0}
|
||||||
<p class="font-medium">
|
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||||||
{booking.services.map((s: BookingService) => s.service_name).join(', ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
|
||||||
{#if booking.total_amount}
|
{#if booking.total_amount}
|
||||||
<p class="text-sm text-gray-500">£{booking.total_amount.toFixed(2)}</p>
|
<span class="text-lg font-bold text-gray-900">
|
||||||
|
£{booking.total_amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
<button
|
||||||
<Button size="sm" onclick={() => openBooking(booking.id)}>View Details</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>
|
||||||
</Card.Content>
|
</div>
|
||||||
</Card.Root>
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user