Add massage as a third vertical, driven by a config registry

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>
This commit is contained in:
Ubuntu
2026-08-13 16:34:47 +00:00
co-authored by Claude Opus 5
parent 0fd7f7a7a5
commit 5c30467fbd
61 changed files with 7130 additions and 169 deletions
@@ -95,8 +95,8 @@
foreach ($groups[$label] as $serviceItem) {
?>
<div class="serviceRow">
<div class="serviceCol serviceNameCol" id="serviceName_<?php echo $serviceItem->service_id; ?>">
<?php echo $serviceItem->service_name; ?>
<div class="serviceCol serviceNameCol" id="serviceName_<?php echo $serviceItem->service_id; ?>" data-service-name="<?php echo htmlspecialchars($serviceItem->service_name, ENT_QUOTES, 'UTF-8'); ?>">
<?php echo vertical_service_label(isset($vertical) ? $vertical : NULL, $serviceItem); ?>
</div>
<div class="serviceCol">
@@ -235,6 +235,26 @@
}
});
<?php if ( ! empty($vertical['single_service_booking'])): ?>
/* Single-service verticals (massage): a guest books one treatment at a
time, so selecting one clears and disables the rest. Clicking the
selected one again releases everything - the escape hatch for a
mis-click. Bound BEFORE the totals handler below, so that handler
always sees the corrected state. */
$('.service').click(function(){
if($(this).is(':checked')){
$('.service').not(this)
.prop('checked', false)
.prop('disabled', true)
.closest('.serviceRow').addClass('serviceRowDisabled');
}
else{
$('.service').prop('disabled', false);
$('.serviceRow').removeClass('serviceRowDisabled');
}
});
<?php endif; ?>
$('.service').click(function(){
var totalPrice = 0;
var selectedServices = '<div class="selectedServiceTable">';
@@ -248,7 +268,10 @@
var selected_id = $(this).attr('id');
selectedPriceElement = document.getElementById('servicePrice_'+selected_id).innerHTML;
totalPrice = totalPrice + parseInt(selectedPriceElement);
selectedNameElement = document.getElementById('serviceName_'+selected_id).innerHTML;
var nameNode = document.getElementById('serviceName_'+selected_id);
// Prefer the full name: the visible label may have had its
// category prefix stripped for readability.
selectedNameElement = nameNode.getAttribute('data-service-name') || nameNode.innerHTML;
selectedServices = selectedServices + '<div class="totalServiceRow"><div class="selectedServiceCol totalSelectedServiceName">'+selectedNameElement+'</div><div class="selectedServiceCol totalSelectedServicePrice">'+selectedPriceElement+' kr</div></div>';
var selectedTotalTime = $('#servicelength').val();
@@ -356,8 +379,13 @@
},
}).done(function(result){
$('#workerTable').html(result.workerListShow);
var firstWorker = result.workers;
setWorker(firstWorker[0].worker_id)
if(result.workers && result.workers.length > 0){
setWorker(result.workers[0].worker_id);
}
else{
$('#workerTable').html('<div class="noWorkerMessage">Nincs elérhető munkatárs ehhez a szolgáltatáshoz.</div>');
setWorker('');
}
});
}