Introduces /massage alongside barber and beauty: landing page, booking
flow, admin support, home tile and SEO entries, in all three languages.
Architecture
- application/config/verticals.php + vertical_helper.php: one registry
entry per vertical (branding, assets, views, behaviour flags). A fourth
vertical is a config entry plus content files.
- Strangler: barber and beauty keep pointing at their existing view files,
so their rendered HTML is unchanged. Only massage uses the new generic
pages/vertical-*.php and includes/vertical-*.php views, which collapse
the four duplicated per-language nav/footer branches into one.
- Pages::vertical() + one route; booking(), booking_finished(),
_booking_error() and manage_booking() are now registry-driven.
Worker/vertical coupling
- getActiveWorkers() derives the vertical from services.service_category_id
instead of the workers.is_barber / is_beauty flags, which were a
hand-maintained cache of exactly that fact. Verified against production
data: the derived set reproduced the stored flags for every worker, in
both verticals. No schema change was needed for massage.
- The legacy flags are now written through from the category so a rollback
cannot strand a new worker, and the admin worker UI shows the derived
verticals read-only instead of two dropdowns that controlled nothing.
Bug fixes found along the way (all pre-existing)
- booking_process() had no server-side category guard; cross-vertical
mixing was prevented only by client-side JS.
- add-service-form / add-worker-form emitted `selected` on every category
option, so the newest category silently became the default.
- update-service-form offered only barber/beauty, so editing a service of
any other type silently rewrote it.
- getWorkerScheduleByDay ignored schedule overrides while getAvailableTimes
honoured them, so slots could be shown and then rejected. Added an
override-aware getWorkerScheduleForDate() and used it in both guards.
- Booking lists dereferenced a null service if one had been hard-deleted.
- main.css: .tiles was tuned for exactly two tiles, including an
absolutely-positioned .style1 at the 1280px breakpoint.
Massage-specific behaviour, opt-in per vertical
- strip_category_prefix: grouped service lists show "50 min" under the
treatment heading rather than repeating the full name. The full name is
carried in data-service-name so the totals panel stays unambiguous, and
services.service_name is untouched for emails and admin.
- single_service_booking: one treatment per booking, enforced in the UI and
in booking_process(). Re-clicking the selection releases it.
- Displayed treatment time (50/80/110 min) is in the service name; the
booked slot (60/90/120 min) is service_time and covers changing and
payment. service_time is never shown to the guest.
DB migrations for dev/prod are in documents/ - additive only, no ALTER.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Customers intermittently hit a full-screen raw JSON error when booking:
{"error":"Conflict. Timeslot is taken or does not fit the service."}
booking_process() re-validates the chosen slot at submit time and returned
409/403 raw JSON. Because the public booking form is a full-page POST, that
JSON filled the whole screen.
The trigger is a double submit. After inserting the booking, booking_process()
synchronously runs two Google Calendar createEvent calls, a lunch sync, an ntfy
push and an SMTP confirmation e-mail before redirecting - several seconds - and
the submit button was never disabled. On mobile the guest taps "Send" again; the
second request arrives after the first has committed, so the slot reads as taken.
Evidence: 168 duplicate booking pairs exist in prod (same guest, slot and worker,
consecutive booking ids, including runs of four). All are from 2025, none from
2026 - the 409 guard added around May 2025 converted those silent duplicates
into today's visible error.
Prevent the double submit:
- disable the submit button and relabel it on first submit, ignore later ones
- add a hidden sendBooking field, since disabling a submit button can drop its
name/value from the POST and booking_process() bails to the homepage without it
Handle it gracefully when it still happens:
- new _booking_error() renders a localised page in the right skin instead of raw
JSON, replacing all six JSON responses in booking_process()
- new booking-error views for barber/beauty in no/en/hu, each with a message per
error case and a link back to booking
- new Service_model::getBookingBySlotAndGuest(); if the guest's own booking for
that exact slot already exists the submit is a duplicate rather than a real
conflict, so finish normally instead of erroring. Guarded on a non-empty
e-mail, as admin block bookings are stored with an empty guest_email.
No schema change. Verified on test, dev and prod: friendly page in all three
languages and both skins, double submit redirects to booking-finished without
creating a duplicate row, and no raw JSON in any response.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJso3iGT7TkW5tm4RSBohs
- Add Europe/Oslo timezone to all DateTime constructors in getAvailableTimes() and
computeLunchBreak() to prevent UTC vs local time mismatch that allowed booking
slots 1 hour past the worker's end time on same-day bookings
- Remove fallback loop in computeLunchBreak() that pushed the lunch break outside
the configured window; lunch break is now strictly enforced within the interval
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>