const API_BASE = window.OT_BOOKING_API_BASE || `${window.location.protocol}//${window.location.hostname || "localhost"}:8787/api`; const form = document.querySelector("#booking-form"); const statusEl = document.querySelector("#status"); const submitBtn = document.querySelector("#submit-btn"); const nameEl = document.querySelector("#name"); const phoneEl = document.querySelector("#phone"); const emailEl = document.querySelector("#email"); const serviceEl = document.querySelector("#service"); const dateEl = document.querySelector("#date"); const timeEl = document.querySelector("#time"); const amountEl = document.querySelector("#amount"); const audienceEl = document.querySelector("#audience"); const toggleButtons = document.querySelectorAll(".toggle-btn"); const eyebrowEl = document.querySelector("#booking-eyebrow"); const headlineEl = document.querySelector("#booking-headline"); const noteEl = document.querySelector("#booking-note"); const previewNameEl = document.querySelector("#preview-name"); const previewEmailEl = document.querySelector("#preview-email"); const previewServiceEl = document.querySelector("#preview-service"); const previewWhenEl = document.querySelector("#preview-when"); const previewAmountEl = document.querySelector("#preview-amount"); const BOOKING_LEAD_MINUTES = 60; let amountEditedManually = false; let currentAudience = "men"; let currentSlots = []; function isAppointmentBookable(date, time) { const appointmentMs = new Date(`${date}T${time}:00`).getTime(); if (Number.isNaN(appointmentMs)) return false; return appointmentMs >= Date.now() + BOOKING_LEAD_MINUTES * 60 * 1000; } const BOOKING_PROFILES = { men: { eyebrow: "OT private access - men", headline: "Premium booking experience", note: "Need the regular site flow too? You can still book from the main OT website anytime.", services: [ { name: "Regular haircut", price: 80 }, { name: "Haircut & black dye", price: 120 }, { name: "Haircut, texturizer & black dye (curls)", price: 180 }, { name: "Waves haircut", price: 180 }, { name: "Haircut, curls & a color dye", price: 220 }, { name: "Haircut with color dye", price: 200 }, { name: "Haircut with white color dye", price: 250 }, { name: "Hair coloring alone (deposit)", price: 100 }, { name: "Shaving with beard", price: 50 }, { name: "Express cut", price: 200 }, { name: "Home service", price: 500 }, { name: "Test payment (GHS 1)", price: 1 }, ], }, women: { eyebrow: "OT private access - women", headline: "Kikisnailslash booking", note: "Need a barber service instead? Switch to Men above before you submit.", services: [ { name: "Men's normal pedicure", price: 200 }, { name: "Men's jelly pedicure", price: 250 }, { name: "Men's full set manicure", price: 120 }, { name: "Women's full set manicure", price: 120 }, { name: "Nails initial deposit", price: 100 }, { name: "Acrylic overlay", price: 120 }, { name: "Classic lashes", price: 140 }, { name: "Hybrid lashes", price: 200 }, { name: "Test payment (GHS 1)", price: 1 }, ], }, }; function setStatus(message, type = "info") { statusEl.textContent = message; statusEl.classList.remove("status-error", "status-success"); if (type === "error") { statusEl.classList.add("status-error"); return; } if (type === "success") { statusEl.classList.add("status-success"); } } function setLoadingState(isLoading) { submitBtn.disabled = isLoading; submitBtn.textContent = isLoading ? "Please wait..." : "Pay and book"; } function formatAppointment(date, time) { if (!date || !time) return "-"; const localDate = new Date(`${date}T${time}:00`); if (Number.isNaN(localDate.getTime())) return "-"; return localDate.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", hour12: true, }); } function updatePreview() { previewNameEl.textContent = nameEl.value.trim() || "-"; previewEmailEl.textContent = emailEl.value.trim() || "-"; previewServiceEl.textContent = serviceEl.value || "-"; previewWhenEl.textContent = formatAppointment(dateEl.value, timeEl.value); previewAmountEl.textContent = Number(amountEl.value) > 0 ? `GHS ${Number(amountEl.value)}` : "GHS -"; } function formatTimeLabel(time) { const [h, m] = String(time || "").split(":").map((v) => Number(v)); if (!Number.isFinite(h) || !Number.isFinite(m)) return time; const d = new Date(); d.setHours(h, m, 0, 0); return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: true }); } function setTimeOptions(slots, previousValue = "", emptyLabel = "No slots available for this date") { currentSlots = slots.slice(); timeEl.innerHTML = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = slots.length ? "Select an available time" : emptyLabel; timeEl.appendChild(placeholder); slots.forEach((slot) => { const option = document.createElement("option"); option.value = slot.time; option.textContent = `${formatTimeLabel(slot.time)}${slot.note ? ` - ${slot.note}` : ""}`; timeEl.appendChild(option); }); const hasPrevious = slots.some((slot) => slot.time === previousValue); timeEl.value = hasPrevious ? previousValue : ""; updatePreview(); } async function loadSlotsForSelectedDate() { const date = String(dateEl.value || "").trim(); const previousTime = String(timeEl.value || ""); if (!date) { setTimeOptions([], ""); return; } try { timeEl.disabled = true; setStatus("Loading available times..."); const response = await fetch(`${API_BASE}/slots?audience=${encodeURIComponent(currentAudience)}&date=${encodeURIComponent(date)}`); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Could not load slots."); setTimeOptions(Array.isArray(data.slots) ? data.slots : [], previousTime); if (data.slots && data.slots.length) { setStatus(""); } else { setStatus("No available slots for this date. Please choose another day.", "error"); } } catch (error) { setTimeOptions([], "", "Could not load times — booking API offline"); const hint = error.message === "Failed to fetch" ? "Booking API is not running. In cPanel open Setup Node.js App, start the booking-api app, then refresh." : error.message || "Failed to load available times."; setStatus(hint, "error"); } finally { timeEl.disabled = false; } } function setAudience(audience) { const nextAudience = audience === "women" ? "women" : "men"; const profile = BOOKING_PROFILES[nextAudience]; currentAudience = nextAudience; audienceEl.value = nextAudience; document.body.classList.toggle("audience-women", nextAudience === "women"); document.body.classList.toggle("audience-men", nextAudience === "men"); if (eyebrowEl) eyebrowEl.textContent = profile.eyebrow; if (headlineEl) headlineEl.textContent = profile.headline; if (noteEl) noteEl.textContent = profile.note; toggleButtons.forEach((button) => { const isActive = button.dataset.audience === nextAudience; button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", String(isActive)); }); const previousValue = serviceEl.value; serviceEl.innerHTML = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = "Select a service"; serviceEl.appendChild(placeholder); profile.services.forEach((service) => { const option = document.createElement("option"); option.value = service.name; option.textContent = service.name; option.dataset.price = String(service.price); serviceEl.appendChild(option); }); const hasSameOption = profile.services.some((service) => service.name === previousValue); serviceEl.value = hasSameOption ? previousValue : ""; amountEditedManually = false; applyServiceSuggestedAmount(); loadSlotsForSelectedDate(); } function applyServiceSuggestedAmount() { const selectedOption = serviceEl.selectedOptions[0]; const suggestedPrice = selectedOption ? Number(selectedOption.dataset.price) : Number.NaN; if (!amountEditedManually && Number.isFinite(suggestedPrice) && suggestedPrice > 0) { amountEl.value = String(suggestedPrice); } updatePreview(); } function setMinDateToToday() { const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); dateEl.min = `${yyyy}-${mm}-${dd}`; } function isValidPhone(phone) { return /^\+233\d{9}$/.test(phone); } function normalizeGhanaPhone(input) { const digitsOnly = String(input || "").replace(/\D/g, ""); if (!digitsOnly) return ""; if (digitsOnly.length === 10 && digitsOnly.startsWith("0")) { return `+233${digitsOnly.slice(1)}`; } if (digitsOnly.length === 9) { return `+233${digitsOnly}`; } return ""; } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function toIsoAppointment(date, time) { return new Date(`${date}T${time}:00`).toISOString(); } async function confirmPaymentFromUrl() { const params = new URLSearchParams(window.location.search); const reference = params.get("reference"); const paymentStatus = params.get("status"); if (!reference || paymentStatus !== "success") return; setStatus("Confirming your payment and booking..."); try { const response = await fetch(`${API_BASE}/booking/confirm?reference=${encodeURIComponent(reference)}`, { method: "POST", }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Could not confirm payment."); } setStatus("Payment confirmed. SMS confirmation has been sent.", "success"); window.history.replaceState({}, "", window.location.pathname); } catch (error) { setStatus(error.message || "Payment confirmation failed.", "error"); } } async function initializePayment(payload) { const response = await fetch(`${API_BASE}/payment/initialize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Unable to start payment."); } return data; } form.addEventListener("submit", async (event) => { event.preventDefault(); const formData = new FormData(form); const name = String(formData.get("name") || "").trim(); const phoneInput = String(formData.get("phone") || "").trim(); const phone = normalizeGhanaPhone(phoneInput); const email = String(formData.get("email") || "").trim(); const service = String(formData.get("service") || "").trim(); const date = String(formData.get("date") || "").trim(); const time = String(formData.get("time") || "").trim(); const amount = Number(formData.get("amount")); if (!name || !phone || !email || !service || !date || !time || !Number.isFinite(amount) || amount <= 0) { setStatus("Please fill all fields correctly.", "error"); return; } if (!isValidPhone(phone)) { setStatus("Enter a valid Ghana number (e.g. 0541234567 or 541234567).", "error"); return; } if (!isValidEmail(email)) { setStatus("Enter a valid email address for your receipt.", "error"); return; } if (!isAppointmentBookable(date, time)) { setStatus(`Please choose a time at least ${BOOKING_LEAD_MINUTES} minutes from now.`, "error"); return; } setLoadingState(true); setStatus("Starting secure payment..."); try { const appointmentAt = toIsoAppointment(date, time); const result = await initializePayment({ customerName: name, customerPhone: phone, customerEmail: email, audience: currentAudience, appointmentDate: date, appointmentTime: time, serviceName: service, amountGhs: amount, appointmentAt, }); if (!result.checkoutUrl) { throw new Error("No checkout URL was returned."); } window.location.href = result.checkoutUrl; } catch (error) { setStatus(error.message || "Could not start payment.", "error"); setLoadingState(false); } }); nameEl.addEventListener("input", updatePreview); emailEl.addEventListener("input", updatePreview); serviceEl.addEventListener("change", applyServiceSuggestedAmount); dateEl.addEventListener("input", updatePreview); dateEl.addEventListener("change", loadSlotsForSelectedDate); timeEl.addEventListener("change", updatePreview); amountEl.addEventListener("input", () => { amountEditedManually = true; updatePreview(); }); phoneEl.addEventListener("blur", () => { if (!phoneEl.value.trim()) return; const normalized = normalizeGhanaPhone(phoneEl.value.trim()); if (!isValidPhone(normalized)) { setStatus("Phone should be like 0541234567 or 541234567.", "error"); return; } phoneEl.value = normalized.replace("+233", ""); if (statusEl.classList.contains("status-error")) { setStatus("Phone looks good. Continue booking."); } }); emailEl.addEventListener("blur", () => { if (!emailEl.value.trim()) return; if (!isValidEmail(emailEl.value.trim())) { setStatus("Email format should look like name@example.com.", "error"); return; } if (statusEl.classList.contains("status-error")) { setStatus("Email looks good. Continue booking."); } }); toggleButtons.forEach((button) => { button.addEventListener("click", () => { setAudience(button.dataset.audience); setStatus(""); }); }); const audienceFromUrl = new URLSearchParams(window.location.search).get("audience"); setAudience(audienceFromUrl); setMinDateToToday(); timeEl.disabled = true; setTimeOptions([], ""); updatePreview(); confirmPaymentFromUrl();