/* 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.