From 6b11cfdeb60ed46fd3f56c728220c7514aab1861 Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Wed, 5 Aug 2026 18:14:35 +0000 Subject: [PATCH] Deploy --- src/app/booking/page.tsx | 327 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/app/booking/page.tsx diff --git a/src/app/booking/page.tsx b/src/app/booking/page.tsx new file mode 100644 index 0000000..39d549a --- /dev/null +++ b/src/app/booking/page.tsx @@ -0,0 +1,327 @@ +'use client' +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { ArrowLeft, Calendar, ChevronLeft, ChevronRight, CircleCheck, Clock, Loader2 } from 'lucide-react' + +const COMMERCE_URL = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL || '' +const PROJECT_ID = process.env.NEXT_PUBLIC_VULA_PROJECT_ID || '' + +// Business-hours appointment slots (30-min intervals, 09:00–17:00) +const BUSINESS_SLOTS = Array.from({ length: 17 }, (_, i) => { + const h = 9 + Math.floor(i / 2) + const m = i % 2 === 0 ? '00' : '30' + return `${String(h).padStart(2,'0')}:${m}` +}) + +interface TimeSlot { + time: string + available: boolean +} + +type Step = 'datetime' | 'details' | 'confirm' + +const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'] +const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'] + +function addMonths(date: Date, n: number): Date { + const d = new Date(date) + d.setMonth(d.getMonth() + n) + return d +} + +function toDateStr(date: Date): string { + return date.toISOString().split('T')[0] +} + +function buildCalendarWeeks(year: number, month: number): (Date | null)[][] { + const first = new Date(year, month, 1) + const last = new Date(year, month + 1, 0) + const weeks: (Date | null)[][] = [] + let week: (Date | null)[] = Array(first.getDay()).fill(null) + for (let d = 1; d <= last.getDate(); d++) { + week.push(new Date(year, month, d)) + if (week.length === 7) { weeks.push(week); week = [] } + } + if (week.length > 0) weeks.push([...week, ...Array(7 - week.length).fill(null)]) + return weeks +} + +export default function AppointmentBookingPage() { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const [step, setStep] = useState('datetime') + const [calMonth, setCalMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1)) + const [selectedDate, setSelectedDate] = useState(null) + const [slots, setSlots] = useState([]) + const [slotsLoading, setSlotsLoading] = useState(false) + const [selectedTime, setSelectedTime] = useState(null) + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [notes, setNotes] = useState('') + const [submitting, setSubmitting] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [error, setError] = useState(null) + + const weeks = buildCalendarWeeks(calMonth.getFullYear(), calMonth.getMonth()) + + useEffect(() => { + if (!selectedDate) return + setSlotsLoading(true) + setSlots([]) + setSelectedTime(null) + const dateStr = toDateStr(selectedDate) + fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings/availability?type=appointment&date=${dateStr}&time_slots=${BUSINESS_SLOTS.join(',')}`) + .then(r => r.json()) + .then((data: { availability?: { slot: string; available: boolean }[] }) => { + const av = data.availability ?? [] + if (av.length > 0) { + setSlots(av.map(a => ({ time: a.slot, available: a.available }))) + } else { + setSlots(BUSINESS_SLOTS.map(s => ({ time: s, available: true }))) + } + }) + .catch(() => setSlots(BUSINESS_SLOTS.map(s => ({ time: s, available: true })))) + .finally(() => setSlotsLoading(false)) + }, [selectedDate]) + + async function submitBooking() { + if (!selectedDate || !selectedTime || !name || !phone) return + setSubmitting(true) + setError(null) + try { + const res = await fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + booking_type: 'appointment', + booking_date: toDateStr(selectedDate), + time_slot: selectedTime, + customer_name: name, + customer_phone: phone, + customer_email: email || undefined, + special_requests: notes || undefined, + }), + }) + if (res.status === 409) { + setError('That time slot was just taken. Please choose another.') + setStep('datetime') + return + } + if (!res.ok) throw new Error('Failed to book') + const data = await res.json() + setConfirmation(data.booking?.confirmation_code ?? 'Confirmed') + } catch { + setError('Booking failed. Please try again or call us directly.') + } finally { + setSubmitting(false) + } + } + + if (confirmation) { + return ( +
+
+
+ +
+
+

Appointment Confirmed!

+

Your booking reference

+

{confirmation}

+
+
+
Date{selectedDate ? selectedDate.toLocaleDateString('en-ZA', { weekday:'long', year:'numeric', month:'long', day:'numeric' }) : ''}
+
Time{selectedTime}
+
Name{name}
+
+

We will contact you to confirm. Please keep your phone on.

+ + Back to home + +
+
+ ) + } + + const morningSlots = slots.filter(s => parseInt(s.time) < 12) + const afternoonSlots = slots.filter(s => { const h = parseInt(s.time); return h >= 12 && h < 17 }) + const eveningSlots = slots.filter(s => parseInt(s.time) >= 17) + + return ( +
+
+ + Back + +

Book a Consultation

+

Select a date and time that works for you

+ + {/* Step indicator */} +
+ {(['datetime','details','confirm'] as Step[]).map((s, i) => ( +
+
{i + 1}
+ + {i < 2 &&
} +
+ ))} +
+ + {error && ( +
{error}
+ )} + + {step === 'datetime' && ( +
+ {/* Calendar */} +
+
+ + {MONTHS[calMonth.getMonth()]} {calMonth.getFullYear()} + +
+
+ {DAYS.map(d =>
{d}
)} +
+ {weeks.map((week, wi) => ( +
+ {week.map((day, di) => { + if (!day) return
+ const isPast = day < today + const isWeekend = day.getDay() === 0 || day.getDay() === 6 + const isSelected = selectedDate ? toDateStr(day) === toDateStr(selectedDate) : false + const disabled = isPast || isWeekend + return ( + + ) + })} +
+ ))} +
+ + {/* Time slots */} + {selectedDate && ( +
+
+ + Available times — {selectedDate.toLocaleDateString('en-ZA', { weekday:'long', month:'long', day:'numeric' })} +
+ {slotsLoading ? ( +
+ ) : slots.length === 0 ? ( +

No slots available for this date.

+ ) : ( +
+ {[{ label: 'Morning', items: morningSlots }, { label: 'Afternoon', items: afternoonSlots }, { label: 'Evening', items: eveningSlots }].filter(g => g.items.length > 0).map(group => ( +
+

{group.label}

+
+ {group.items.map(slot => ( + + ))} +
+
+ ))} +
+ )} +
+ )} + +
+ +
+
+ )} + + {step === 'details' && ( +
+
+ +
+

{selectedDate?.toLocaleDateString('en-ZA', { weekday:'long', month:'long', day:'numeric', year:'numeric' })}

+

{selectedTime}

+
+ +
+
+
+ + setName(e.target.value)} placeholder="Your full name" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" /> +
+
+ + setPhone(e.target.value)} placeholder="+27 82 000 0000" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" /> +
+
+
+ + setEmail(e.target.value)} placeholder="your@email.com" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" /> +
+
+ +