From 6dff155067134a1c1622cc62a34db7da9cba3eb8 Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Mon, 3 Aug 2026 11:42:13 +0000 Subject: [PATCH] Deploy --- src/app/booking/[productId]/page.tsx | 199 +++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/app/booking/[productId]/page.tsx diff --git a/src/app/booking/[productId]/page.tsx b/src/app/booking/[productId]/page.tsx new file mode 100644 index 0000000..68b1b5a --- /dev/null +++ b/src/app/booking/[productId]/page.tsx @@ -0,0 +1,199 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import { useProducts } from '@/hooks/useProducts' +import Image from 'next/image' +import Link from 'next/link' +import { ArrowLeft, CalendarCheck, CircleCheck, Loader2 } from 'lucide-react' + +const COMMERCE_URL = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL || '' +const PROJECT_ID = process.env.NEXT_PUBLIC_VULA_PROJECT_ID || '' +const TIME_SLOTS = Array.from({ length: 19 }, (_, i) => { + const h = 8 + Math.floor(i / 2) + const m = i % 2 === 0 ? '00' : '30' + return `${String(h).padStart(2,'0')}:${m}` +}) +const CURRENCY_SYMBOLS: Record = { zar: 'R', usd: '$', eur: '€', gbp: '£', kes: 'KSh', ngn: '₦', ghs: '₵' } +const formatPrice = (amount: number, code?: string) => { + const sym = CURRENCY_SYMBOLS[(code ?? '').toLowerCase()] ?? (code?.toUpperCase() ?? '') + return `${sym}${(amount / 100).toFixed(0)}` +} + +export default function BookingPage() { + const params = useParams() + const productId = params.productId as string + + const { products, loading: loadingSvc } = useProducts(50) + const service = products.find(p => p.id === productId) ?? null + const [form, setForm] = useState({ name: '', email: '', phone: '', date: '', time: '', notes: '' }) + const [bookedSlots, setBookedSlots] = useState([]) + const [loadingSlots, setLoadingSlots] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [confirmationCode, setConfirmationCode] = useState('') + const [success, setSuccess] = useState(false) + const [error, setError] = useState('') + + // Fetch booked slots whenever date changes + useEffect(() => { + if (!form.date || !productId) return + setLoadingSlots(true) + setForm(f => ({ ...f, time: '' })) + fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings/availability?type=appointment&date=${form.date}&time_slots=${TIME_SLOTS.join(',')}`) + .then(r => r.json()) + .then(d => { + const av: { slot: string; available: boolean }[] = d.availability ?? [] + setBookedSlots(av.filter(a => !a.available).map(a => a.slot)) + setLoadingSlots(false) + }) + .catch(() => setLoadingSlots(false)) + }, [form.date, productId]) + + const price = service?.variants?.[0]?.prices?.[0] + const today = new Date().toISOString().split('T')[0] + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!form.time) { setError('Please select a time slot.'); return } + setSubmitting(true) + setError('') + 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', + product_id: productId, + booking_date: form.date, + time_slot: form.time, + customer_name: form.name, + customer_phone: form.phone, + customer_email: form.email || undefined, + special_requests: form.notes || undefined, + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Booking failed') + setConfirmationCode(data.booking?.confirmation_code ?? '') + setSuccess(true) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Something went wrong. Please try again or call us directly.') + } + setSubmitting(false) + } + + if (loadingSvc) return
+ if (!service) return

Service not found.

Back to Services
+ + if (success) return ( +
+
+
+ +

Appointment Confirmed!

+

We'll confirm via WhatsApp or phone call shortly.

+
+ {confirmationCode && ( +
+ Reference + {confirmationCode} +
+ )} +
Service{service.title}
+
Date{form.date}
+
Time{form.time}
+
+ Browse More Services +
+
+
+ ) + + return ( +
+
+ + Back to Services + +
+
+ {service.thumbnail && ( +
+ {service.title} +
+ )} +

{service.title}

+ {service.description &&

{service.description}

} + {price &&

{formatPrice(price.amount, price.currency_code)}

} +
+
+
+

+ Book an Appointment +

+
+
+ + setForm(f => ({...f, date: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+ {form.date && ( +
+
+ + {loadingSlots && } +
+
+ {TIME_SLOTS.map(slot => { + const booked = bookedSlots.includes(slot) + const selected = form.time === slot + return ( + + ) + })} +
+
+ )} +
+
+ + setForm(f => ({...f, name: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+
+ + setForm(f => ({...f, phone: e.target.value}))} + placeholder="+27 " className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+
+ + setForm(f => ({...f, email: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+
+ +