/* Fluffy Friends — full booking page (shareable at /book → /site/#book). Pick a groomer → pet & service → date & a real time-slot → pin location → details. Submitting creates a PENDING booking on the dashboard calendar and opens a pre-filled WhatsApp message. */ const { Button, Input, Select, Checkbox, Badge } = window.FluffyFriendsDesignSystem_a09c32; // Pull lat/lng out of a full Google Maps URL (no network needed). function _latLngFromMapsUrl(u) { const m = (u || '').match(/@(-?\d{1,3}\.\d+),(-?\d{1,3}\.\d+)/) || (u || '').match(/!3d(-?\d{1,3}\.\d+)!4d(-?\d{1,3}\.\d+)/) || (u || '').match(/[?&](?:q|query|ll|center|destination)=(-?\d{1,3}\.\d+),\s*(-?\d{1,3}\.\d+)/); return m ? { lat: +m[1], lng: +m[2] } : null; } function _looksLikeMapsUrl(t) { return /^https?:\/\//i.test((t || '').trim()) && /(google\.[a-z.]+\/maps|maps\.google\.|maps\.app\.goo\.gl|goo\.gl\/maps)/i.test(t); } let _mapsPromise = null; function loadGoogleMaps(key) { if (window.google && window.google.maps && window.google.maps.places) return Promise.resolve(true); if (!key) return Promise.reject(new Error('no-key')); if (_mapsPromise) return _mapsPromise; _mapsPromise = new Promise((resolve, reject) => { window.__flfMapsReady = () => resolve(true); const s = document.createElement('script'); s.src = `https://maps.googleapis.com/maps/api/js?key=${key}&libraries=places&callback=__flfMapsReady`; s.async = true; s.defer = true; s.onerror = () => reject(new Error('maps-load-failed')); document.head.appendChild(s); }); return _mapsPromise; } function BookingPage({ initialPet, onNav }) { const D = window.BOOKING_DATA; const petIdRef = React.useRef(0); const newPet = (petKey) => { const k = petKey || 'dog'; return { id: ++petIdRef.current, pet: k, catAge: 'adult', size: 'small', service: '', extras: {}, instruction: '' }; }; const [groomers, setGroomers] = React.useState([]); const [groomer, setGroomer] = React.useState('any'); // key or 'any' const [pets, setPets] = React.useState(() => [newPet(initialPet || 'dog')]); const [date, setDate] = React.useState(''); const [slots, setSlots] = React.useState([]); const [slotsLoading, setSL] = React.useState(false); const [slot, setSlot] = React.useState(null); // {start,end,label} const [form, setForm] = React.useState({ name: '', phone: '', notes: '', hear: '' }); // Attribution: captured once from the tracked-link query string (src + UTM) and // the referrer. No customer personal data is ever read into these. const attribution = React.useMemo(() => { const q = new URLSearchParams(window.location.search); return { src: q.get('src') || '', utm_source: q.get('utm_source') || '', utm_medium: q.get('utm_medium') || '', utm_campaign: q.get('utm_campaign') || '', raw_referrer: document.referrer || '', }; }, []); const [loc, setLoc] = React.useState({ address: '', lat: null, lng: null, building: '', street: '', apartment: '' }); const [mapsOk, setMapsOk] = React.useState(null); // null=loading, true/false const [submitting, setSubmit] = React.useState(false); const [done, setDone] = React.useState(false); const addrRef = React.useRef(null); const mapRef = React.useRef(null); const mapObj = React.useRef(null); const marker = React.useRef(null); // Load groomers + Google Maps once. React.useEffect(() => { fetch('/api/public/groomers').then(r => r.json()).then(j => setGroomers(j.groomers || [])).catch(() => {}); fetch('/api/public/config').then(r => r.json()).then(cfg => { loadGoogleMaps(cfg.maps_key).then(() => setMapsOk(true)).catch(() => setMapsOk(false)); }).catch(() => setMapsOk(false)); }, []); // ── Pet list helpers ── const addPet = () => setPets(p => [...p, newPet('dog')]); const removePet = (id) => setPets(p => (p.length > 1 ? p.filter(x => x.id !== id) : p)); const updatePet = (id, patch) => setPets(p => p.map(x => (x.id === id ? { ...x, ...patch } : x))); // changing the pet type resets the service/extras/age to valid defaults const changePet = (id, key) => updatePet(id, { pet: key, service: '', extras: {}, catAge: 'adult', size: 'small' }); const togglePetExtra = (id, exKey) => setPets(p => p.map(x => (x.id === id ? { ...x, extras: { ...x.extras, [exKey]: !x.extras[exKey] } } : x))); const petLabelOf = (pt) => pt.pet === 'cat' ? `Cat (${pt.catAge})` : pt.pet === 'dog' ? `Dog (${pt.size})` : D.PET_OPTS.find(p => p.key === pt.pet).label; // Dogs are priced by size (D.BASE.dog[size][service]); cats by age; others flat. const priceFor = (pt, svc) => (pt.pet === 'cat' ? (D.BASE.cat[pt.catAge] || {})[svc] : pt.pet === 'dog' ? ((D.BASE.dog[pt.size] || {})[svc]) : (D.BASE[pt.pet] || {})[svc]) || 0; const petPrice = (pt) => { const base = priceFor(pt, pt.service); const ex = D.EXTRAS.filter(x => pt.extras[x.key]); return { base, ex, total: base + ex.reduce((s, x) => s + x.price, 0) }; }; // Total groom time to reserve — sum of each pet's typical duration (dogs are // size-based). This blocks the groomer's schedule, so multi-pet bookings hold // a real slot. const petDuration = (pt) => { if (D.SERVICE_DURATIONS && D.SERVICE_DURATIONS[pt.service]) return D.SERVICE_DURATIONS[pt.service]; return pt.pet === 'dog' ? (D.DOG_SIZES.find(s => s.key === pt.size) || D.DOG_SIZES[0]).duration : (D.DURATIONS[pt.pet] || 60); }; const duration = pets.reduce((s, pt) => s + petDuration(pt), 0); const durationLabel = duration >= 60 ? `${Math.floor(duration / 60)}h${duration % 60 ? ` ${duration % 60}m` : ''}` : `${duration}m`; const selectedProf = (() => { if (groomer === 'any') return ''; const g = groomers.find(x => x.key === groomer); return (g && g.professional) || ''; })(); // Fetch availability whenever groomer or date changes. React.useEffect(() => { if (!date) { setSlots([]); setSlot(null); return; } setSL(true); setSlot(null); const qs = new URLSearchParams({ day: date, professional: selectedProf, duration: String(duration) }); fetch(`/api/public/slots?${qs}`).then(r => r.json()) .then(j => setSlots(j.slots || [])) .catch(() => setSlots([])) .finally(() => setSL(false)); }, [date, groomer, groomers, duration]); // eslint-disable-line // Init the map + Places autocomplete once Maps is ready and the inputs exist. React.useEffect(() => { if (mapsOk !== true || !addrRef.current || mapObj.current) return; const g = window.google; const center = { lat: 25.2048, lng: 55.2708 }; // Dubai const map = new g.maps.Map(mapRef.current, { center, zoom: 11, mapTypeControl: false, streetViewControl: false }); const mk = new g.maps.Marker({ map, position: center, draggable: true, visible: false }); mapObj.current = map; marker.current = mk; const place = (ll) => { mk.setPosition(ll); mk.setVisible(true); setLoc(l => ({ ...l, lat: ll.lat(), lng: ll.lng() })); }; mk.addListener('dragend', () => place(mk.getPosition())); map.addListener('click', (e) => place(e.latLng)); // tap the map to drop a pin const ac = new g.maps.places.Autocomplete(addrRef.current, { componentRestrictions: { country: 'ae' }, fields: ['geometry', 'formatted_address'], }); ac.addListener('place_changed', () => { const place = ac.getPlace(); if (!place.geometry) return; const ll = place.geometry.location; map.setCenter(ll); map.setZoom(15); mk.setPosition(ll); mk.setVisible(true); setLoc({ address: place.formatted_address || addrRef.current.value, lat: ll.lat(), lng: ll.lng() }); }); }, [mapsOk]); // Paste a Google Maps link → drop the pin. Full URLs parse locally; short // links (maps.app.goo.gl) are resolved by the public backend resolver. const resolveMapsLink = React.useCallback(async (url) => { let c = _latLngFromMapsUrl(url); if (!c) { try { const res = await fetch('/api/public/geo/resolve?url=' + encodeURIComponent(url)); const j = await res.json(); if (res.ok && j.lat != null) c = { lat: j.lat, lng: j.lng }; } catch (e) { /* ignore */ } } if (!c) { if (addrRef.current) addrRef.current.value = ''; setLoc(l => ({ ...l, address: '' })); alert("We couldn't read that Google Maps link. Please type your address or drop the pin on the map."); return; } setLoc(l => ({ ...l, lat: c.lat, lng: c.lng })); const g = window.google; if (g && mapObj.current && marker.current) { const ll = new g.maps.LatLng(c.lat, c.lng); mapObj.current.setCenter(ll); mapObj.current.setZoom(15); marker.current.setPosition(ll); marker.current.setVisible(true); new g.maps.Geocoder().geocode({ location: ll }, (r, s) => { if (s === 'OK' && r[0]) { if (addrRef.current) addrRef.current.value = r[0].formatted_address; setLoc(l => ({ ...l, address: r[0].formatted_address })); } }); } else { const label = '📍 Pinned from Google Maps link'; if (addrRef.current) addrRef.current.value = label; setLoc(l => ({ ...l, address: label })); } }, []); React.useEffect(() => { const el = addrRef.current; if (!el) return; const onPaste = (e) => { const t = (e.clipboardData || window.clipboardData).getData('text') || ''; if (_looksLikeMapsUrl(t)) { e.preventDefault(); resolveMapsLink(t.trim()); } }; const onBlur = () => { if (_looksLikeMapsUrl(el.value)) resolveMapsLink(el.value.trim()); }; el.addEventListener('paste', onPaste); el.addEventListener('blur', onBlur); return () => { el.removeEventListener('paste', onPaste); el.removeEventListener('blur', onBlur); }; }, [resolveMapsLink, mapsOk]); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const setAddr = (k, v) => setLoc(l => ({ ...l, [k]: v })); const addrDetail = [ loc.building && `Villa/Bldg ${loc.building}`, loc.street && loc.street, loc.apartment && `Apt/Floor ${loc.apartment}`, ].filter(Boolean).join(', '); const total = pets.reduce((s, pt) => s + petPrice(pt).total, 0); const groomerName = groomer === 'any' ? 'Any available groomer' : (groomers.find(g => g.key === groomer) || {}).name || ''; const allServicesPicked = pets.every(pt => pt.service); const canSubmit = date && slot && form.name.trim() && form.phone.trim() && allServicesPicked && !submitting; // One-line summary per pet, e.g. 'Cat (adult): Full groom + Teeth brushing — Mochi, sensitive skin' const petSummary = (pt) => { const ex = petPrice(pt).ex.map(x => x.label); return `${petLabelOf(pt)}: ${pt.service}${ex.length ? ` + ${ex.join(', ')}` : ''}${pt.instruction ? ` — ${pt.instruction}` : ''}`; }; const buildMessage = () => { const lines = [ `Hi Fluffy Friends! 🐾 I'd like to book a groom for ${pets.length} pet${pets.length > 1 ? 's' : ''}:`, ]; pets.forEach((pt, i) => { const pr = petPrice(pt); lines.push(`• ${pets.length > 1 ? `Pet ${i + 1} — ` : ''}${petLabelOf(pt)}: ${pt.service} — AED ${pr.base}`); if (pr.ex.length) lines.push(` add-ons: ${pr.ex.map(x => `${x.label} (AED ${x.price})`).join(', ')}`); if (pt.instruction) lines.push(` instructions: ${pt.instruction}`); }); lines.push(`• Estimated total: AED ${total}`); lines.push(`• Groomer: ${groomerName}`); if (date && slot) lines.push(`• When: ${date} at ${slot.label}`); if (form.name) lines.push(`• Name: ${form.name}`); if (form.phone) lines.push(`• Mobile: ${form.phone}`); if (loc.address) lines.push(`• Location: ${loc.address}`); if (addrDetail) lines.push(`• Address details: ${addrDetail}`); if (form.notes) lines.push(`• Notes: ${form.notes}`); return lines.join('\n'); }; const submit = async () => { if (!canSubmit) return; // Open WhatsApp immediately, while we're still inside the click gesture. // Opening it after `await fetch(...)` gets suppressed by the popup blocker // (the call is no longer considered user-initiated), so WhatsApp never opened. openWhatsApp(buildMessage()); setSubmit(true); const fullAddress = [loc.address, addrDetail].filter(Boolean).join(' · '); const petTypes = [...new Set(pets.map(pt => D.PET_OPTS.find(p => p.key === pt.pet).label))].join(', '); const serviceType = pets.length === 1 ? (pets[0].pet === 'cat' ? `${pets[0].service} (${pets[0].catAge})` : pets[0].service) : `${pets.length} pets`; const payload = { day: date, professional: selectedProf, customer_name: form.name, customer_phone: form.phone, booking_start: `${date} ${slot.start}`, booking_end: `${date} ${slot.end}`, service_type: serviceType, pet_type: petTypes, lat: loc.lat, lng: loc.lng, amount: total, address: fullAddress, notes: [pets.map(petSummary).join(' · '), form.notes].filter(Boolean).join(' · '), source: 'website', // Attribution (page handle + UTM from the tracked link; manual selection). ...attribution, hear_about: form.hear, }; try { // Save the booking to the dashboard in the background. WhatsApp is already // open with the full details, so the request reaches us either way. const res = await fetch('/api/public/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error('save-failed'); } catch (e) { /* best-effort: details already sent via WhatsApp */ } finally { setSubmit(false); setDone(true); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; if (done) { return (

Booking request sent! 🐾

Thank you, {form.name.split(' ')[0]}. We've received your request for {date} at {slot.label} with {groomerName}, and opened WhatsApp so you can send it through. Our team will confirm shortly and bring the pink van to your door.

); } return (
Book your groom

Let's get your friend looking fabulous

Choose your groomer, a time that suits you, and where to bring the van. We'll confirm by WhatsApp.

{/* 1 — groomer */}

1 Choose your groomer

{groomers.map(g => ( ))}
{/* 2 — pets & services (one block per pet) */}

2 Your pets & services

{pets.map((pt, i) => { const showExtras = pt.pet === 'dog' || pt.pet === 'cat'; return (
Pet {i + 1} {pets.length > 1 && ( )}
{D.PET_OPTS.map(p => ( ))}
{pt.pet === 'cat' && (
)} {pt.pet === 'dog' && (
)}
{showExtras && (
{D.EXTRAS.map(x => ( togglePetExtra(pt.id, x.key)} /> ))}
)}
updatePet(pt.id, { instruction: e.target.value })} />
); })}
{/* 3 — date & time */}

3 Pick a date & time

We'll reserve about {durationLabel} for {pets.length > 1 ? `your ${pets.length} pets` : 'your pet'}.

setDate(e.target.value)} /> {date && (
{slotsLoading ?

Finding open slots…

: slots.length === 0 ?

No open slots that day — try another date or groomer.

:
{slots.map(s => ( ))}
}
)}
{/* 4 — location */}

4 Where should we come?

setLoc(l => ({ ...l, address: e.target.value }))} />
{mapsOk === false &&

Map unavailable — just type your address above and we'll find you.

} {loc.lat &&

📍 Pin set ({loc.lat.toFixed(4)}, {loc.lng.toFixed(4)})

}
setAddr('building', e.target.value)} /> setAddr('street', e.target.value)} />
setAddr('apartment', e.target.value)} />
{/* 5 — details */}

5 Your details

set('name', e.target.value)} /> set('phone', e.target.value)} />
set('notes', e.target.value)} />
{/* summary rail */}
); } function BookStyles() { return ; } Object.assign(window, { BookingPage });