/**
* 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 && (
{t('back')}
{step === 4 ? t('review') : t('continue')} →
)}
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')}
);
}
/* ================================================================== *
* 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.address}
{l.phone && (
{l.phone}
)}
))}
);
}
function LocationThumb({ location }) {
if (location.picture) {
return (
{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 (
);
}
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}
setViewMonth(v => v.m === 0 ? {y: v.y - 1, m: 11} : {y: v.y, m: v.m - 1})}>‹
setViewMonth(v => v.m === 11 ? {y: v.y + 1, m: 0} : {y: v.y, m: v.m + 1})}>›
{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 => (
setState(v => ({...v, slot: s}))}>
{s}
))
) : (
{t('cal_no_avail')}
)
) : (
{t('cal_no_date')}
)}
);
}
function LegendDot({ color, border, label, opacity = 1 }) {
return (
);
}
/* ================================================================== *
* 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')}
{t('sign_in')}
);
}
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 && (
)}
);
}
/* ================================================================== *
* 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')}
onNav && onNav('home')}>{t('back_to_site')}
);
}
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}
onNav && onNav('home')}>{t('back_to_site')}
);
}
/* ================================================================== *
* 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 (
{t('summary_sub')}
{t('summary_title')}
|
{services.length > 0 && (
{t('confirm_labels.total')} · {unit}
{totalPrice}
)}
{/* summary-cta block stays exactly as it is */}
{t('steps_of', { n: Math.min(step + 1, 6) })}
{[0,1,2,3,4,5].map(i => (
))}
{t('summary_secure')}
);
}
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 });