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