diff --git a/src/app/b/[code]/page.tsx b/src/app/b/[code]/page.tsx new file mode 100644 index 0000000..2543297 --- /dev/null +++ b/src/app/b/[code]/page.tsx @@ -0,0 +1,221 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { CalendarCheck, CircleCheck, CircleX, Loader2, TriangleAlert } from 'lucide-react' + +const COMMERCE_URL = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL || '' +const PROJECT_ID = process.env.NEXT_PUBLIC_VULA_PROJECT_ID || '' + +type Booking = { + confirmation_code: string + status: string + product_name: string | null + booking_date: string | null + check_in_date: string | null + check_out_date: string | null + time_slot: string | null + customer_name: string | null + policy_text: string | null + cancel_deadline: string | null +} + +// A date column arrives as a timestamp, not a day. Appending a time to it +// gives Invalid Date, so take the first ten characters and build from those. +function prettyDay(value: string | null): string { + if (!value) return '' + const d = new Date(value.slice(0, 10) + 'T00:00:00') + if (isNaN(d.getTime())) return '' + return d.toLocaleDateString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) +} + +// "Tuesday 3 June, 08:30" — a moment. Working out what "three hours before" +// means is arithmetic on a phone, at the point someone is least inclined. +function prettyMoment(value: string | null): string { + if (!value) return '' + const d = new Date(value) + if (isNaN(d.getTime())) return '' + return d.toLocaleString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) +} + +export default function ManageBookingPage() { + const params = useParams() + const code = String(params?.code ?? '').toUpperCase() + + const [booking, setBooking] = useState(null) + const [loading, setLoading] = useState(true) + const [missing, setMissing] = useState(false) + const [confirming, setConfirming] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + if (!code) { setLoading(false); setMissing(true); return } + fetch(COMMERCE_URL + '/api/store/' + PROJECT_ID + '/bookings/confirm/' + code) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (d?.success && d.booking) setBooking(d.booking as Booking) + else setMissing(true) + }) + .catch(() => setMissing(true)) + .finally(() => setLoading(false)) + }, [code]) + + // Whether cancelling now counts as late. Worked out here as well as on the + // server so the patient is told BEFORE they tap, not after — the whole + // purpose of the policy is that nobody is surprised by a charge. + const isLate = (() => { + if (!booking?.cancel_deadline) return false + const by = new Date(booking.cancel_deadline) + if (isNaN(by.getTime())) return false + return new Date() > by + })() + + async function cancelBooking() { + setCancelling(true) + try { + const res = await fetch(COMMERCE_URL + '/api/store/' + PROJECT_ID + '/bookings/' + code + '/cancel', { method: 'POST' }) + const d = await res.json().catch(() => ({})) + // The server's sentence goes straight through. It knows whether this was + // inside the notice period; the browser's clock is a courtesy, not the + // record. + setMessage(d?.message || d?.error || 'Your appointment is cancelled.') + setBooking(b => b ? { ...b, status: 'cancelled' } : b) + } catch { + setMessage('We could not cancel that just now — please phone us.') + } finally { + setCancelling(false) + setConfirming(false) + } + } + + const when = booking?.check_in_date + ? prettyDay(booking.check_in_date) + (booking.check_out_date ? ' to ' + prettyDay(booking.check_out_date) : '') + : prettyDay(booking?.booking_date ?? null) + + return ( +
+
+ {loading && ( +
+
+ )} + + {!loading && missing && ( +
+
+ )} + + {!loading && booking && ( +
+
+
+
+

{booking.product_name || 'Appointment'}

+

{booking.confirmation_code}

+
+ +
+ {booking.customer_name && ( +
+
Name
+
{booking.customer_name}
+
+ )} +
+
Date
+
{when || '—'}
+
+ {booking.time_slot && ( +
+
Time
+
{booking.time_slot}
+
+ )} +
+
Status
+
{booking.status}
+
+
+ + {booking.status === 'cancelled' ? ( +
+
+
+
+ ) : message ? ( +
+
+
+
+ ) : ( +
+ {booking.policy_text && ( +
+

Cancellation policy

+

{booking.policy_text}

+ {booking.cancel_deadline && !isLate && ( +

Free to cancel until {prettyMoment(booking.cancel_deadline)}.

+ )} +
+ )} + + {isLate && ( +
+
+ )} + + {!confirming ? ( + + ) : ( +
+

Cancel this booking?

+
+ + +
+
+ )} +
+ )} +
+ )} +
+
+ ) +}