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
- New /ledigestilling/ index listing all open positions (split
barber/beauty card styling)
- Three ads: negletekniker + massør (beauty design), barber (gold
barber design with recolored CSS + assets)
- Rename ledigestilling.html -> negletekniker.html with 301 redirect
- "Ledige stillinger" footer link on barber/beauty pages (NO/EN/HU)
- Back-to-list link on each ad page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hitting /booking-process via GET (or any POST without sendBooking) fell
through past the form-submit guard and tripped three undefined-variable
warnings on the final sendEmail() call. Bail out to the site root
early, matching the pattern in manage_booking_process.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Guests modifying a booking saw every worker for the subpage, so an
eyelash booking exposed nail-only workers as switchable. Now the worker
list is scoped to the booking's service category, other-category
services are disabled in step 1, and the process handler rejects any
worker/service category mismatch to defend against crafted POSTs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The language switcher in barber/beauty headers reads
$currentPageUrlArray[2] unguarded after exploding REQUEST_URI on '/'.
On routes with no language prefix (e.g. POST /manage-booking-cancel
rendering the cancelled page inline), the array has only 2 elements,
triggering "Undefined array key 2" warnings and producing malformed
language links like https://studiobeve.no/en//.
Normalize the array with += [2 => '', 3 => ''] right after the explode
so indices 2 and 3 always exist.
The strftime() result was immediately overwritten by a hardcoded
Hungarian month-name array. Drop the dead line so PHP 8.1+ stops
emitting an E_DEPRECATED warning when prod loads the worker calendar.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Admin create/modify booking notifications now match the public
confirmation/modify emails. Admin-created bookings get a manage_token
generated; admin-modified bookings reuse the existing token (or
backfill one if missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- getAvailableTimes() takes optional exclude_booking_id so a guest's
own booking isn't counted as a conflict when editing — original time
now reappears when extending services
- Manage-booking AJAX passes manage_token; server resolves to booking_id
- manage_booking_process uses the new param instead of the date-swap
workaround (removes a small race-condition risk)
- Modify-booking emails (no/en/hu) now include Name/Email/Phone rows
and the 24h cancellation policy, matching the original booking email
The security hardening commit accidentally rendered every input value as
value=\"...\" (literal backslash-quote in HTML), which mangled all
submitted fields including the hidden worker_id/service_id, causing
UPDATE to match zero rows and silently no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New weekly calendar at /bookings/calendar with time blocks per booking
- Color-coded by worker, overlapping bookings shown side-by-side
- Click booking to see details, edit or delete
- Worker filter dropdown, week navigation (prev/next/today)
- AJAX week loading for smooth navigation
- Link between list view and calendar view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>