376 lines
12 KiB
Svelte
376 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { resolve } from '$app/paths';
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||
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';
|
||
|
||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||
let bookings = $state<Booking[]>([]);
|
||
let loading = $state(false);
|
||
let selectedBookingId = $state<string | null>(null);
|
||
let showBookingModal = $state(false);
|
||
const businessSettings = $derived(getBusinessInfo());
|
||
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (authStore.isLoading) {
|
||
pageState = 'loading';
|
||
return;
|
||
}
|
||
if (!authStore.isAuthenticated) {
|
||
pageState = 'unauthorized';
|
||
<!-- svelte-ignore no-navigation-without-resolve -->
|
||
goto('/login', { replaceState: true });
|
||
return;
|
||
}
|
||
pageState = 'authorized';
|
||
ensureBusinessInfo();
|
||
fetchBookings();
|
||
});
|
||
|
||
async function fetchBookings() {
|
||
loading = true;
|
||
try {
|
||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
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);
|
||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||
return endTime > now;
|
||
})
|
||
.sort(
|
||
(a: Booking, b: Booking) =>
|
||
new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
|
||
);
|
||
} catch (err) {
|
||
console.error('Error fetching bookings:', err);
|
||
toast.error('Network error');
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
function openBooking(id: string) {
|
||
selectedBookingId = id;
|
||
showBookingModal = true;
|
||
}
|
||
|
||
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 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 (i)}
|
||
<div 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 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 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="space-y-6">
|
||
{#each Array(3) as _, i (i)}
|
||
<div 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}
|
||
<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-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-gray-200"></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 top-0 left-0 h-full w-1 {cfg.bar}"></div>
|
||
|
||
<div class="pt-4 pr-5 pb-4 pl-5 md:pt-5 md:pr-6 md:pb-5 md:pl-6">
|
||
<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 leading-none font-medium {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)}
|
||
{#if businessSettings?.is_vat_registered}
|
||
<span class="text-xs font-normal text-gray-400"> incl. VAT</span>
|
||
{/if}
|
||
</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>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId || ''} />
|