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
@@ -7,6 +7,7 @@
<select class="formDropdownBox" style="padding:0;" name="service_type">
<option value="beauty" selected>beauty</option>
<option value="barber">barber</option>
<option value="massage">massage</option>
</select>
</div>
<div class="formRow">
@@ -59,7 +60,7 @@
<?php
if(!empty($serviceCategories)){
foreach($serviceCategories as $serviceCategoryItem){
echo '<option value="'.$serviceCategoryItem->service_category_id.'" selected>'.$serviceCategoryItem->serv_cat_name.'</option>';
echo '<option value="'.$serviceCategoryItem->service_category_id.'">'.$serviceCategoryItem->serv_cat_name.'</option>';
}
}
?>
@@ -22,18 +22,11 @@
</select>
</div>
<div class="formRow">
<label class="formTitle">Beauty</label>
<select class="formDropdownBox" style="padding:0;" name="is_beauty">
<option value="1" selected>igen</option>
<option value="0">nem</option>
</select>
</div>
<div class="formRow">
<label class="formTitle">Barber</label>
<select class="formDropdownBox" style="padding:0;" name="is_barber">
<option value="1" selected>igen</option>
<option value="0">nem</option>
</select>
<label class="formTitle">Vertikálok</label>
<div class="formInputBox" style="border:0;padding-left:0;font-size:11px;color:#888;">
A dolgozó vertikáljait (barber / beauty / massage) az alább választott
szolgáltatás kategória határozza meg - nem kell külön beállítani.
</div>
</div>
<div class="formRow">
<label class="formTitle">Szolgáltatás kategória</label>
@@ -41,7 +34,7 @@
<?php
if(!empty($serviceCategories)){
foreach($serviceCategories as $serviceCategoryItem){
echo '<option value="'.$serviceCategoryItem->service_category_id.'" selected>'.$serviceCategoryItem->serv_cat_name.'</option>';
echo '<option value="'.$serviceCategoryItem->service_category_id.'">'.$serviceCategoryItem->serv_cat_name.'</option>';
}
}
?>
@@ -7,6 +7,7 @@
<select class="formDropdownBox" style="padding:0;" name="service_type">
<option value="beauty" <?php echo $selectedItem->service_type=='beauty'?'selected':'';?>>beauty</option>
<option value="barber" <?php echo $selectedItem->service_type=='barber'?'selected':'';?>>barber</option>
<option value="massage" <?php echo $selectedItem->service_type=='massage'?'selected':'';?>>massage</option>
</select>
</div>
<div class="formRow">
@@ -18,18 +18,18 @@
<input type="text" class="formInputBox" name="worker_info" value="<?php echo htmlspecialchars($selectedItem->worker_info, ENT_QUOTES, 'UTF-8');?>">
</div>
<div class="formRow">
<label class="formTitle">Beauty</label>
<select class="formDropdownBox" style="padding:0;" name="is_beauty">
<option value="1" <?php echo $selectedItem->is_beauty?'selected':'';?>>igen</option>
<option value="0" <?php echo !$selectedItem->is_beauty?'selected':'';?>>nem</option>
</select>
</div>
<div class="formRow">
<label class="formTitle">Barber</label>
<select class="formDropdownBox" style="padding:0;" name="is_barber">
<option value="1" <?php echo $selectedItem->is_barber?'selected':'';?>>igen</option>
<option value="0" <?php echo !$selectedItem->is_barber?'selected':'';?>>nem</option>
</select>
<label class="formTitle">Vertikálok</label>
<div class="formInputBox" style="border:0;padding-left:0;">
<?php
$workerVerticals = isset($workerVerticals) ? $workerVerticals : array();
echo $workerVerticals
? htmlspecialchars(implode(', ', $workerVerticals), ENT_QUOTES, 'UTF-8')
: '<span style="color:#b00;">nincs szolgáltatás ebben a kategóriában</span>';
?>
<div style="font-size:11px;color:#888;margin-top:4px;">
A szolgáltatás kategóriából származtatva - a kategória módosításával változik.
</div>
</div>
</div>
<div class="formRow">
@@ -11,8 +11,7 @@
<td>Profilkép</td>
<td>Név</td>
<td>Leírás</td>
<td>Beauty</td>
<td>Barber</td>
<td>Vertikálok</td>
<td>Szerviz kategória</td>
<td>Aktív</td>
</tr>
@@ -30,8 +29,7 @@
<td><img src="<?php echo SITEURL.'assets/img/'.$resultItem->worker_profile_img;?>" style="width:50px;margin:0;" /></td>
<td><?php echo htmlspecialchars($resultItem->worker_name, ENT_QUOTES, 'UTF-8');?></td>
<td><?php echo htmlspecialchars($resultItem->worker_info, ENT_QUOTES, 'UTF-8');?></td>
<td><?php echo $resultItem->is_beauty?'igen':'nem';?></td>
<td><?php echo $resultItem->is_barber?'igen':'nem';?></td>
<td><?php echo $resultItem->derived_verticals !== null && $resultItem->derived_verticals !== '' ? htmlspecialchars($resultItem->derived_verticals, ENT_QUOTES, 'UTF-8') : '<span style="color:#b00;">nincs szolgáltatás</span>';?></td>
<td><?php echo htmlspecialchars($resultItem->serv_cat_name, ENT_QUOTES, 'UTF-8');?></td>
<td><?php echo $resultItem->is_active?'igen':'nem';?></td>
</tr>