/** * Mrbarber — Booking wizard (Amelia Elite REST integration) * --------------------------------------------------------- * Browser-compiled via Babel Standalone — no build step. */ const { useState, useEffect, useMemo, useCallback } = React; /* ================================================================== * * i18n — reads window.MrbI18n.t (the 'booking' dict section) * * ================================================================== */ const _i18n = (window.MrbI18n && window.MrbI18n.t) || {}; function t(key, vars) { const parts = key.split('.'); let node = _i18n; for (const p of parts) { if (node && typeof node === 'object' && p in node) node = node[p]; else return key; } if (typeof node !== 'string') return node; if (vars) return node.replace(/\{(\w+)\}/g, (_, k) => vars[k] != null ? String(vars[k]) : '{' + k + '}'); return node; } // Day names (Mon-first, index 0=Mon … 6=Sun) and month names from dictionary. const DOW = Array.isArray(_i18n.day_short) ? _i18n.day_short : ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const MONTHS = Array.isArray(_i18n.month_names) ? _i18n.month_names : ['January','February','March','April','May','June','July','August','September','October','November','December']; // Convert JS getDay() (0=Sun) to Monday-first DOW index. function dowName(jsDay) { return DOW[(jsDay + 6) % 7]; } /* ================================================================== * * Amelia API client — talks to the WP proxy, which adds the API key. * * ================================================================== */ const cfg = window.MrbBooking || {}; async function ameliaCall(method, endpoint, body = null) { const url = new URL(cfg.proxyUrl); url.searchParams.set('action', 'mrb_amelia'); url.searchParams.set('nonce', cfg.nonce); url.searchParams.set('endpoint', endpoint); const opts = { method, credentials: 'same-origin' }; if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } const res = await fetch(url.toString(), opts); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } if (!res.ok) { const msg = json?.data?.message || json?.message || `HTTP ${res.status}`; throw new Error(msg); } return json; } const Amelia = { getLocations: () => ameliaCall('GET', '/locations'), getServices: () => ameliaCall('GET', '/services'), getProviders: (locationId) => ameliaCall('GET', `/users/providers?location=${locationId}`), getProviderDetail: (id) => ameliaCall('GET', `/users/providers/${id}`), getSlots: ({ serviceId, providerId, locationId, from, to, durationSeconds, persons = 1 }) => { const params = new URLSearchParams({ serviceId: String(serviceId), persons: String(persons), startDateTime: from, endDateTime: to, extras: '[]', }); if (durationSeconds) params.set('serviceDuration', String(durationSeconds)); if (providerId) params.append('providerIds[0]', String(providerId)); if (locationId) params.set('locationId', String(locationId)); return ameliaCall('GET', `/slots?${params.toString()}`); }, createBooking: (payload) => ameliaCall('POST', '/bookings', payload), }; /* ================================================================== * * Response normalizers * * ================================================================== */ function normalizeLocations(raw) { const list = raw?.data?.locations || []; return list .filter(l => l.status === 'visible') .map(l => ({ id: l.id, name: l.name, address: l.address || '', phone: l.phone || '', area: l.description || '', tag: l.pin ? 'Flagship' : 'Atelier', chairs: l.customCapacity || null, picture: l.pictureFullPath || null, })); } function normalizeServices(raw) { const list = raw?.data?.services || []; return list .filter(s => s.status === 'visible' && s.show !== false) .map(s => ({ id: s.id, name: s.name, desc: s.description || '', price: Number(s.price) || 0, duration: Math.round((s.duration || 0) / 60), unit: cfg.currency || 'MKD', categoryId: s.categoryId, })); } function normalizeProviderObject(u) { if (!u) return null; const locIds = new Set(); if (u.locationId) locIds.add(u.locationId); (u.weekDayList || []).forEach(day => { (day.periodList || []).forEach(period => { if (period.locationId) locIds.add(period.locationId); (period.periodLocationList || []).forEach(pl => { if (pl.locationId) locIds.add(pl.locationId); }); }); }); const svcIds = new Set(); (u.serviceList || []).forEach(s => svcIds.add(s.id ?? s)); (u.weekDayList || []).forEach(day => { (day.periodList || []).forEach(period => { (period.periodServiceList || []).forEach(ps => { if (ps.serviceId) svcIds.add(ps.serviceId); }); }); }); return { id: u.id, name: [u.firstName, u.lastName].filter(Boolean).join(' '), firstName: u.firstName || '', role: u.note || 'Barber', tenure: '', locationIds: Array.from(locIds), locationId: u.locationId || null, serviceIds: Array.from(svcIds), picture: u.pictureFullPath || null, }; } function normalizeProviderDetail(raw) { const user = raw?.data?.user; return normalizeProviderObject(user); } function providerWorksAt(p, locationId) { if (!locationId) return true; if (p.locationIds && p.locationIds.length > 0) return p.locationIds.includes(locationId); if (p.locationId) return p.locationId === locationId; return true; } function selectedServiceIds(state) { return Array.isArray(state.services) ? state.services : []; } function isExclusiveService(service) { return String(service?.desc || '').trim() === '1'; } function getSelectedServices(catalog, state) { const ids = selectedServiceIds(state); return catalog.services.filter(s => ids.includes(s.id)); } function getPrimaryService(catalog, state) { const ids = selectedServiceIds(state); return catalog.services.find(s => s.id === ids[0]) || null; } function getSelectedDurationMinutes(services) { return services.reduce((sum, s) => sum + (Number(s.duration) || 0), 0); } function getSelectedTotalPrice(services) { return services.reduce((sum, s) => sum + (Number(s.price) || 0), 0); } /* ================================================================== * * Validation helpers * * ================================================================== */ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function validateEmail(email) { const e = (email || '').trim(); if (!e) return t('err_email_req'); if (!EMAIL_RE.test(e)) return t('err_email_bad'); return null; } function onlyDigits(s) { return (s || '').replace(/\D/g, ''); } function formatMkPhone(input) { const d = onlyDigits(input).slice(0, 9); if (d.length <= 3) return d; if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`; return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`; } function validateMkPhone(input) { const d = onlyDigits(input); if (d.length === 0) return t('err_phone_req'); if (d.length !== 9) return t('err_phone_len', { n: d.length }); if (d[0] !== '0') return t('err_phone_start'); return null; } function toE164Mk(input) { const d = onlyDigits(input); if (d.length !== 9) return input; return '+389' + d.slice(1); } function fromE164Mk(input) { if (!input) return ''; const digits = onlyDigits(input); let national; if (input.trim().startsWith('+389') || digits.startsWith('389')) { national = '0' + digits.slice(3); } else if (digits.startsWith('0')) { national = digits; } else { return ''; } return formatMkPhone(national); } /* ================================================================== * * Top-level BookingPage * * ================================================================== */ function BookingPage({ onNav }) { const [step, setStep] = useState(0); const [user, setUser] = useState(cfg.user || null); const [signInOpen, setSignInOpen] = useState(false); const prefillName = user ? (user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ')) : ''; const [state, setState] = useState({ location: null, services: [], barber: null, // was: service: null date: null, slot: null, name: prefillName, email: user?.email || '', phone: fromE164Mk(user?.phone || ''), }); const handleLoginSuccess = (newUser, payload) => { setUser(newUser); if (payload.newNonce) cfg.nonce = payload.newNonce; if (payload.logoutUrl) cfg.logoutUrl = payload.logoutUrl; setState(s => ({ ...s, name: newUser.displayName || [newUser.firstName, newUser.lastName].filter(Boolean).join(' ') || s.name, email: newUser.email, phone: fromE164Mk(newUser.phone || '') || s.phone, })); setSignInOpen(false); }; const [catalog, setCatalog] = useState({ locations: [], services: [], providers: [], loading: true, error: null, providersStatus: 'idle', providersError: null, }); useEffect(() => { let alive = true; (async () => { try { const [locRes, svcRes] = await Promise.all([ Amelia.getLocations(), Amelia.getServices(), ]); if (!alive) return; setCatalog(c => ({ ...c, locations: normalizeLocations(locRes), services: normalizeServices(svcRes), loading: false, })); } catch (err) { if (alive) setCatalog(c => ({ ...c, loading: false, error: err.message })); } })(); return () => { alive = false; }; }, []); useEffect(() => { if (!state.location) return; let alive = true; setCatalog(c => ({ ...c, providersStatus: 'loading', providersError: null })); (async () => { try { const listRes = await Amelia.getProviders(state.location); const shallow = listRes?.data?.users || []; if (!alive) return; if (shallow.length === 0) { setCatalog(c => ({ ...c, providers: [], providersStatus: 'loaded' })); return; } const detailResponses = await Promise.all( shallow .filter(u => u.status !== 'hidden') .map(u => Amelia.getProviderDetail(u.id).catch(err => { console.warn(`[Mrb] detail fetch failed for provider ${u.id}:`, err.message); return null; }) ) ); if (!alive) return; const providers = detailResponses .map(r => (r ? normalizeProviderDetail(r) : null)) .filter(Boolean); setCatalog(c => ({ ...c, providers, providersStatus: 'loaded' })); } catch (err) { console.error('[Mrb] providers fetch failed:', err); if (alive) setCatalog(c => ({ ...c, providersStatus: 'error', providersError: err.message, })); } })(); return () => { alive = false; }; }, [state.location]); const selectedLocation = catalog.locations.find(l => l.id === state.location); const selectedServices = catalog.services.filter(s => state.services.includes(s.id)); // was find/selectedService const selectedBarber = catalog.providers.find(b => b.id === state.barber); const step5Valid = user ? !!user.email : (state.name.trim() && !validateEmail(state.email) && !validateMkPhone(state.phone)); const canAdvance = [ !!state.location, !!state.barber, state.services.length > 0, // was !!state.service !!state.date && !!state.slot, step5Valid, true, ][step]; const steps = [ t('steps.location'), t('steps.barber'), t('steps.service'), t('steps.schedule'), t('steps.details'), t('steps.confirm'), ]; const next = () => setStep(s => Math.min(s + 1, steps.length - 1)); const prev = () => setStep(s => Math.max(s - 1, 0)); if (catalog.loading) return ; if (catalog.error && !catalog.locations.length) return ; return (
{t('page_eyebrow')}

{t('page_title_pre')}{' '} {t('page_title_post')}

{t('page_body')}

{steps.map((label, i) => (
i < step && setStep(i)} style={{cursor: i < step ? 'pointer' : 'default'}}> {i < step ? '✓' : i + 1} {label}
))}
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && setSignInOpen(true)} />} {step === 5 && } {step < 5 && (
)}
setSignInOpen(false)} onSuccess={handleLoginSuccess} />
); } /* ================================================================== * * Loading / error states * * ================================================================== */ function LoadingState() { return (
{t('loading_eyebrow')}

{t('loading_title_pre')} {t('loading_title_post')}

{t('loading_body')}

); } function ErrorState({ message }) { return (
{t('error_eyebrow')}

{t('error_title_pre')} {t('error_title_post')}

{message}

{t('error_body')}

); } /* ================================================================== * * Inline sign-in modal * * ================================================================== */ function SignInModal({ open, onClose, onSuccess }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [remember, setRemember] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!open) { setEmail(''); setPassword(''); setError(null); setLoading(false); } }, [open]); useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; const submit = async (e) => { e.preventDefault(); if (loading) return; setLoading(true); setError(null); try { const body = new FormData(); body.append('action', 'mrb_login'); body.append('nonce', cfg.nonce); body.append('username', email.trim()); body.append('password', password); if (remember) body.append('remember', '1'); const res = await fetch(cfg.proxyUrl, { method: 'POST', body, credentials: 'same-origin', }); const json = await res.json(); if (!json || json.success !== true) { const msg = json?.data?.message || t('signin_err_generic'); throw new Error(msg); } onSuccess(json.data.user, json.data); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return (
e.stopPropagation()} style={{background: 'var(--ink-2)', border: '1px solid var(--gold)', padding: 40, width: '100%', maxWidth: 440, boxShadow: 'var(--shadow-deep)'}}>
{t('signin_eyebrow')}

{t('signin_title_pre')} {t('signin_title_post')}

{t('signin_body')}

setEmail(e.target.value)} autoComplete="username" autoFocus placeholder={t('ph_email')} />
setPassword(e.target.value)} autoComplete="current-password" placeholder="••••••••" />
{error && (
{error}
)}
{t('signin_forgot')} {t('signin_new')}
); } /* ================================================================== * * STEP 0 — Location * * ================================================================== */ function StepLocation({ state, setState, catalog }) { return (
{t('steps_of', { n: 1 })}

{t('step1_title_pre')} {t('step1_title_post')}

{catalog.locations.map(l => (
setState(v => ({...v, location: l.id, barber: null, services: [], date: null, slot: null}))} style={{gridTemplateColumns: '1fr', gap: 0}}>

{l.name}

{l.tag}

{l.address}

{l.phone && (

{l.phone}

)}
))}
); } function LocationThumb({ location }) { if (location.picture) { return (
{location.name}
{location.tag}
); } return (
{location.tag}
{location.name} · Storefront
); } /* ================================================================== * * STEP 1 — Barber * * ================================================================== */ function StepBarber({ state, setState, catalog }) { const loc = catalog.locations.find(l => l.id === state.location); const locBarbers = catalog.providers.filter(b => providerWorksAt(b, state.location)); const options = [ //{ id: 'any', name: t('any_barber'), role: t('first_available'), tenure: '—' }, ...locBarbers, ]; const { providersStatus, providersError } = catalog; const countText = locBarbers.length === 1 ? t('step2_sub_count', { n: locBarbers.length }) : t('step2_sub_count_plural', { n: locBarbers.length }); return (
{t('steps_of', { n: 2 })}

{t('step2_title_pre')} {t('step2_title_post')}

{/* {t('step2_sub', { location: loc?.name || '' })} {providersStatus === 'loaded' && ` · ${countText}`} */}

{providersStatus === 'loading' &&

{t('step2_loading')}

} {providersStatus === 'error' && (
{t('step2_err_eyebrow')}

{providersError}

)} {providersStatus === 'loaded' && locBarbers.length === 0 && (
{t('step2_no_barbers_eyebrow')}

{t('step2_no_barbers')}

)} {providersStatus === 'loaded' && locBarbers.length > 0 && (
{options.map(b => (
setState(v => ({...v, barber: b.id, services: [], date: null, slot: null}))}>

{b.id === 'any' ? t('any_barber') : b.firstName || b.name.split(' ')[0]}

{b.role}
))}
)}
); } function BarberThumb({ barber }) { if (barber.picture) { return (
{barber.name}
); } return (
{barber.id === 'any' ? '✦' : (barber.firstName || barber.name.split(' ')[0])}
); } /* ================================================================== * * STEP 2 — Service * * ================================================================== */ function StepService({ state, setState, catalog }) { const relevantBarbers = state.barber === 'any' ? catalog.providers.filter(p => providerWorksAt(p, state.location)) : catalog.providers.filter(p => p.id === state.barber); const serviceIdsOffered = new Set(relevantBarbers.flatMap(p => p.serviceIds)); const hasAnyAssignments = relevantBarbers.some(p => p.serviceIds.length > 0); const services = hasAnyAssignments ? catalog.services.filter(s => serviceIdsOffered.has(s.id)) : catalog.services; const pickedBarber = catalog.providers.find(p => p.id === state.barber); const subtitle = state.barber === 'any' ? t('step3_sub_any') : pickedBarber ? t('step3_sub_one', { name: pickedBarber.firstName || pickedBarber.name }) : null; // A description can list several group tokens separated by "|". // e.g. "2|2.1" -> the service belongs to groups "2" AND "2.1". // Blank description -> a single unique token so it never collides with anything. const groupsOf = (s) => { const raw = String(s.desc || '').trim(); if (raw === '') return [`__svc_${s.id}`]; return raw.split('|').map(x => x.trim()).filter(Boolean); }; const selectedIds = state.services || []; // Every group token currently occupied by a selected service. const lockedGroups = new Set( services .filter(s => selectedIds.includes(s.id)) .flatMap(groupsOf) ); const toggle = (s) => setState(v => { const cur = v.services || []; const next = cur.includes(s.id) ? cur.filter(id => id !== s.id) // deselect : [...cur, s.id]; // select return { ...v, services: next, date: null, slot: null }; }); return (
{t('steps_of', { n: 3 })}

{t('step3_title_pre')} {t('step3_title_post')}

{subtitle &&

{subtitle}

} {services.length === 0 ? (

{t('step3_no_services')}

) : (
{services.map(s => { const isSelected = selectedIds.includes(s.id); // Disabled if not selected and it shares ANY group with a selection. const disabled = !isSelected && groupsOf(s).some(g => lockedGroups.has(g)); return (
{ if (!disabled) toggle(s); }} style={{ opacity: disabled ? 0.35 : 1, cursor: disabled ? 'not-allowed' : 'pointer' }}>

{s.name}

{t('duration_min', { n: s.duration })}
{s.price} {s.unit}
); })}
)}
); } /* ================================================================== * * STEP 3 — Schedule * * ================================================================== */ function StepSchedule({ state, setState, catalog }) { const today = useMemo(() => { const t = new Date(); t.setHours(0, 0, 0, 0); return t; }, []); const [viewMonth, setViewMonth] = useState({ y: today.getFullYear(), m: today.getMonth() }); const [monthSlots, setMonthSlots] = useState({ data: null, loading: false, error: null }); const selectedServices = getSelectedServices(catalog, state); const primaryService = getPrimaryService(catalog, state); const durationMinutes = getSelectedDurationMinutes(selectedServices); const durationSeconds = durationMinutes > 0 ? durationMinutes * 60 : undefined; const selectedServicesKey = selectedServiceIds(state).join(','); useEffect(() => { if (!primaryService) return; let alive = true; setMonthSlots({ data: null, loading: true, error: null }); const first = new Date(viewMonth.y, viewMonth.m, 1); const last = new Date(viewMonth.y, viewMonth.m + 1, 0); const from = fmtDateTime(first, '00:00'); const to = fmtDateTime(last, '23:59'); Amelia.getSlots({ serviceId: primaryService.id, providerId: state.barber === 'any' ? null : state.barber, locationId: state.location, from, to, durationSeconds, persons: 1, }) .then(res => { if (alive) setMonthSlots({ data: res, loading: false, error: null }); }) .catch(err => { if (alive) setMonthSlots({ data: null, loading: false, error: err.message }); }); return () => { alive = false; }; }, [viewMonth.y, viewMonth.m, selectedServicesKey, state.barber, state.location, durationSeconds]); const firstDay = new Date(viewMonth.y, viewMonth.m, 1); const startDow = (firstDay.getDay() + 6) % 7; const daysInMonth = new Date(viewMonth.y, viewMonth.m + 1, 0).getDate(); const prevMonthDays = new Date(viewMonth.y, viewMonth.m, 0).getDate(); const cells = []; for (let i = startDow - 1; i >= 0; i--) cells.push({ d: prevMonthDays - i, other: true }); for (let d = 1; d <= daysInMonth; d++) cells.push({ d, other: false }); while (cells.length % 7 !== 0) cells.push({ d: cells.length - daysInMonth - startDow + 1, other: true }); const isSelected = (c) => !c.other && state.date && state.date.y === viewMonth.y && state.date.m === viewMonth.m && state.date.d === c.d; const isToday = (c) => !c.other && viewMonth.y === today.getFullYear() && viewMonth.m === today.getMonth() && c.d === today.getDate(); const isPast = (c) => { if (c.other) return true; return new Date(viewMonth.y, viewMonth.m, c.d) < today; }; const availableDays = useMemo(() => { const set = new Set(); const slots = monthSlots.data?.data?.slots || {}; Object.keys(slots).forEach(dateKey => { if (Object.keys(slots[dateKey]).length > 0) set.add(dateKey); }); return set; }, [monthSlots.data]); const hasSlots = (c) => { if (c.other) return false; const key = `${viewMonth.y}-${String(viewMonth.m + 1).padStart(2, '0')}-${String(c.d).padStart(2, '0')}`; return availableDays.has(key); }; const setDate = (c) => { if (isPast(c) || !hasSlots(c)) return; setState(v => ({...v, date: { y: viewMonth.y, m: viewMonth.m, d: c.d }, slot: null})); }; const slotsForDay = useMemo(() => { if (!state.date || !monthSlots.data) return []; const key = `${state.date.y}-${String(state.date.m + 1).padStart(2, '0')}-${String(state.date.d).padStart(2, '0')}`; const bucket = monthSlots.data?.data?.slots?.[key] || {}; return Object.keys(bucket).map(t => t.slice(0, 5)).sort(); }, [state.date, monthSlots.data]); const slotDayLabel = state.date ? `${dowName(new Date(state.date.y, state.date.m, state.date.d).getDay())} · ${state.date.d} ${MONTHS[state.date.m].slice(0, 3)}` : t('select_date'); return (
{t('steps_of', { n: 4 })}

{t('step4_title_pre')} {t('step4_title_post')}

{MONTHS[viewMonth.m]} {viewMonth.y}

{DOW.map(d =>
{d}
)} {cells.map((c, i) => { const disabled = isPast(c) || !hasSlots(c); return (
setDate(c)}> {c.d}
); })}
{monthSlots.loading && (
{t('cal_loading')}
)} {monthSlots.error && (
{monthSlots.error}
)}
{slotDayLabel}
{state.date ? ( slotsForDay.length > 0 ? ( slotsForDay.map(s => ( )) ) : (
{t('cal_no_avail')}
) ) : (
{t('cal_no_date')}
)}
); } function LegendDot({ color, border, label, opacity = 1 }) { return (
{label}
); } /* ================================================================== * * STEP 4 — Details (user banner + form only for guests) * * ================================================================== */ function UserStateBanner({ user, onSignIn }) { if (user) { const displayName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email; return (
{t('welcome_back')}
{t('booking_as')} {displayName}
{user.email} {user.phone && · {fromE164Mk(user.phone) || user.phone}}
{t('sign_out')}
); } return (
{t('returning')}
{t('returning_body')}
); } function StepDetails({ state, setState, user, onSignIn }) { const [touched, setTouched] = useState({}); const update = (k) => (e) => { let val = e.target.value; if (k === 'phone') val = formatMkPhone(val); setState(v => ({...v, [k]: val})); }; const markTouched = (k) => () => setTouched(t => ({...t, [k]: true})); const emailError = touched.email ? validateEmail(state.email) : null; const phoneError = touched.phone ? validateMkPhone(state.phone) : null; const errorStyle = { color: 'var(--danger)', fontSize: 11, fontFamily: 'var(--mono)', letterSpacing: '0.06em', marginTop: 4, textTransform: 'uppercase', }; return (
{t('steps_of', { n: 5 })}

{t('step5_title_pre')} {t('step5_title_post')}

{!user && (
+389
{phoneError &&
{phoneError}
}
{emailError &&
{emailError}
}
)}
); } /* ================================================================== * * STEP 5 — Confirm (posts the booking) * * ================================================================== */ function StepConfirm({ state, catalog, user, onNav }) { // Selection order matters for chaining, so map the ordered IDs -> service objects. const orderedServices = state.services .map(id => catalog.services.find(s => s.id === id)) .filter(Boolean); const serviceNames = orderedServices.map(s => s.name).join(', '); const durationMinutes = getSelectedDurationMinutes(orderedServices); const barber = catalog.providers.find(b => b.id === state.barber); const location = catalog.locations.find(l => l.id === state.location); const d = state.date ? new Date(state.date.y, state.date.m, state.date.d) : null; const dateStr = d ? `${dowName(d.getDay())}, ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}` : ''; const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); const [ref, setRef] = useState(null); useEffect(() => { if (status !== 'idle') return; if (orderedServices.length === 0) { setStatus('error'); setError(t('step3_no_services')); return; } let providerId = state.barber; if (providerId === 'any') { const pick = catalog.providers.find(p => providerWorksAt(p, state.location) && state.services.every(id => p.serviceIds.length === 0 || p.serviceIds.includes(id)) ); providerId = pick?.id; } if (!providerId) { setStatus('error'); setError(t('err_no_barber')); return; } const firstName = user ? (user.firstName || user.displayName || '') : (state.name.trim().split(/\s+/)[0] || state.name); const lastName = user ? (user.lastName || '') : (state.name.trim().split(/\s+/).slice(1).join(' ') || ''); const email = user ? user.email : state.email.trim(); const phoneRaw = user ? user.phone : state.phone; const phone = phoneRaw ? (phoneRaw.startsWith('+') ? phoneRaw : toE164Mk(phoneRaw)) : ''; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const pad = (n) => String(n).padStart(2, '0'); const fmtStart = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; // Running start cursor, beginning at the chosen slot. const [sh, sm] = state.slot.split(':').map(Number); let cursor = new Date(state.date.y, state.date.m, state.date.d, sh, sm, 0, 0); const buildPayload = (svc, bookingStart, durationSeconds) => ({ type: 'appointment', bookings: [{ extras: [], customFields: {}, deposit: true, locale: cfg.locale || 'en_US', utcOffset: null, persons: 1, customerId: null, customer: { id: null, firstName, lastName, email, phone, countryPhoneIso: 'mk', externalId: null, }, duration: durationSeconds, }], payment: { gateway: 'onSite', currency: cfg.currency || 'USD', data: {} }, bookingStart, notifyParticipants: 1, locationId: state.location, providerId, serviceId: svc.id, locale: cfg.locale || 'en_US', timeZone, couponCode: null, runInstantPostBookingActions: true, }); setStatus('submitting'); (async () => { const refs = []; try { for (const svc of orderedServices) { const durMin = svc.duration || 30; const durSec = Math.max(60, durMin * 60); const bookingStart = fmtStart(cursor); const res = await Amelia.createBooking(buildPayload(svc, bookingStart, durSec)); const appointmentId = res?.data?.appointment?.id; const bookingId = res?.data?.booking?.id ?? res?.data?.appointment?.bookings?.[0]?.id; if (!appointmentId && !bookingId) { const serverMsg = res?.message || JSON.stringify(res).slice(0, 300); throw new Error( `Amelia accepted "${svc.name}" but returned no appointment ID. Server said: ${serverMsg}` ); } refs.push(`MRB-${appointmentId || bookingId}`); // Advance the cursor by this service's duration for the next one. cursor = new Date(cursor.getTime() + durMin * 60000); } setRef(refs.join(', ')); setStatus('success'); } catch (err) { const partial = refs.length ? ` (${refs.length} of ${orderedServices.length} were already booked: ${refs.join(', ')})` : ''; setError(err.message + partial); setStatus('error'); } })(); }, []); if (status === 'submitting') { return (
{t('confirm_in_progress')}

{t('step6_submitting_title')}{' '} {t('step6_submitting_post')}

{t('step6_submitting_body')}

); } if (status === 'error') { return (
{t('booking_failed_eyebrow')}

{t('step6_fail_title')}

{error}

{t('step6_fail_body')}

); } const displayEmail = user ? user.email : state.email; const barberLabel = state.barber === 'any' ? t('any_barber') : barber?.name; return (
{t('confirm_eyebrow')}

{t('step6_title_pre')}{' '} {t('step6_title_post')}

{t('confirm_body', { email: displayEmail || t('confirm_email_fallback') })}

{t('confirm_labels.location')}
{location?.name}
{location?.address}
{t('confirm_labels.service')}
{serviceNames}
{t('confirm_labels.barber')}
{barberLabel}
{t('confirm_labels.duration')}
{durationMinutes ? t('duration_min', { n: durationMinutes }) : ''}
{t('confirm_labels.date')}
{dateStr}
{t('confirm_labels.time')}
{state.slot}
{t('confirm_labels.reference')}
{ref}
); } /* ================================================================== * * Summary aside * * ================================================================== */ function BookingSummary({ state, location, services, barber, step }) { const d = state.date ? new Date(state.date.y, state.date.m, state.date.d) : null; const dateStr = d ? `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}` : null; const barberName = state.barber === 'any' ? t('any_barber') : barber?.name; const totalDuration = services.reduce((sum, s) => sum + s.duration, 0); const totalPrice = services.reduce((sum, s) => sum + s.price, 0); const unit = services[0]?.unit; const serviceNames = services.map(s => s.name).join(', '); return ( ); } function Row({ label, val }) { return (
{label}
{val || t('confirm_labels.not_set')}
); } /* ================================================================== * * Utilities * * ================================================================== */ function fmtDateTime(date, time) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d} ${time}`; } /* ================================================================== * * Mount * * ================================================================== */ function mrbMount() { const el = document.getElementById('mrb-booking-root'); if (!el) return; el.removeAttribute('data-loading'); el.innerHTML = ''; const root = ReactDOM.createRoot(el); root.render(React.createElement(BookingPage, { onNav: (where) => { if (where === 'home') window.location.href = '/'; }, })); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mrbMount); } else { mrbMount(); } Object.assign(window, { MrbBookingPage: BookingPage, MrbAmelia: Amelia });