Deploy
This commit is contained in:
@@ -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<Booking | null>(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 (
|
||||
<main className="min-h-screen bg-background py-16 px-4">
|
||||
<div className="max-w-lg mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-3 py-24 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" aria-hidden="true" />
|
||||
<span>Finding your booking…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && missing && (
|
||||
<div className="rounded-2xl border border-border bg-card p-8 text-center">
|
||||
<TriangleAlert className="w-8 h-8 mx-auto mb-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold mb-2">We couldn't find that booking</h1>
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
Check the reference in your confirmation message, or phone us and we'll find it for you.
|
||||
</p>
|
||||
<Link href="/" className="text-primary underline underline-offset-4">Back to the website</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && booking && (
|
||||
<div className="rounded-2xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-8 pt-8 pb-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-1 text-sm text-muted-foreground">
|
||||
<CalendarCheck className="w-4 h-4" aria-hidden="true" />
|
||||
<span>Your booking</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{booking.product_name || 'Appointment'}</h1>
|
||||
<p className="font-mono tracking-widest text-primary mt-2">{booking.confirmation_code}</p>
|
||||
</div>
|
||||
|
||||
<dl className="px-8 py-6 space-y-3 text-sm">
|
||||
{booking.customer_name && (
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-muted-foreground">Name</dt>
|
||||
<dd className="text-right">{booking.customer_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-muted-foreground">Date</dt>
|
||||
<dd className="text-right">{when || '—'}</dd>
|
||||
</div>
|
||||
{booking.time_slot && (
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-muted-foreground">Time</dt>
|
||||
<dd className="text-right">{booking.time_slot}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
<dd className="text-right capitalize">{booking.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{booking.status === 'cancelled' ? (
|
||||
<div className="px-8 pb-8">
|
||||
<div className="flex items-start gap-3 rounded-xl bg-muted p-4 text-sm">
|
||||
<CircleX className="w-5 h-5 shrink-0 mt-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
<p>{message || 'This booking is cancelled.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : message ? (
|
||||
<div className="px-8 pb-8">
|
||||
<div className="flex items-start gap-3 rounded-xl bg-muted p-4 text-sm">
|
||||
<CircleCheck className="w-5 h-5 shrink-0 mt-0.5 text-primary" aria-hidden="true" />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-8 pb-8 space-y-4">
|
||||
{booking.policy_text && (
|
||||
<div className="rounded-xl bg-muted p-4">
|
||||
<p className="font-medium text-sm mb-2">Cancellation policy</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{booking.policy_text}</p>
|
||||
{booking.cancel_deadline && !isLate && (
|
||||
<p className="text-sm mt-3">Free to cancel until {prettyMoment(booking.cancel_deadline)}.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLate && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-border p-4 text-sm">
|
||||
<TriangleAlert className="w-5 h-5 shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<p>The notice period for this booking has passed. You can still cancel, and the policy above will apply.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="w-full rounded-xl border border-border px-6 py-3 font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel this booking
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-center">Cancel this booking?</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="flex-1 rounded-xl border border-border px-6 py-3 font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
Keep it
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelBooking}
|
||||
disabled={cancelling}
|
||||
className="flex-1 rounded-xl bg-primary text-primary-foreground px-6 py-3 font-medium disabled:opacity-60"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Yes, cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user