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
@@ -0,0 +1,60 @@
<?php
/**
* Generic site footer for any vertical.
*
* Expects $vertical and $selectedLang.
*
* Replaces four near-identical <footer> copies per vertical file. The address,
* org number and map link are salon-wide; only the social accounts and the map
* image are per-vertical, and both come from the registry.
*/
$labels = array(
'en' => array(
'orgnr' => 'Organisation number',
'vacancies' => 'Open positions',
'copyright' => '&copy; Studio Beve - All rights reserved.',
),
'no' => array(
'orgnr' => 'Organisasjonsnummer',
'vacancies' => 'Ledige stillinger',
'copyright' => '&copy; Studio Beve - All rights reserved.',
),
'hu' => array(
'orgnr' => 'Cégjegyzékszám',
'vacancies' => 'Állásajánlatok',
'copyright' => '&copy; Studio Beve - All rights reserved.',
),
);
$lang = vertical_lang(isset($selectedLang) ? $selectedLang : 'en');
$t = $labels[$lang];
$mapUrl = 'https://www.google.com/maps/place/Studio+Beve/@63.4370671,10.4348909,19z/data=!3m1!4b1!4m6!3m5!1s0x466d31f2b1166d2b:0x76228adf28216c74!8m2!3d63.4370671!4d10.4355346!16s%2Fg%2F11vlvkh6w4?entry=ttu';
?>
<!-- Footer -->
<footer id="footer">
<div class="container footer-div">
<div class="footer-bal">
<div class="contact-block">
<ul>
<li class="companyname"><span>Studio Beve AS</span></li>
<li><span>Innherredsveien 92.<br> 7043 Trondheim, Norway</span></li>
<li><span><?php echo $t['orgnr'];?>: 932 941 619</span></li>
<li><span><a href="mailto:info@studiobeve.no">info@studiobeve.no</a></span></li>
</ul>
</div>
<ul class="icons">
<li><a href="<?php echo $vertical['facebook'];?>" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="<?php echo $vertical['instagram'];?>" class="icon brands fa-instagram"><span class="label">Instagram</span></a></li>
</ul>
<a href="<?php echo SITEURL;?>ledigestilling/" class="footer-vacancies"><?php echo $t['vacancies'];?></a>
</div>
<div class="footer-jobb">
<a href="<?php echo $mapUrl;?>" target="_blank"><img src="<?php echo SITEURL.$vertical['map_img'];?>"> </a>
</div>
</div>
<div class="copyright">
<?php echo $t['copyright'];?>
</div>
</footer>
@@ -0,0 +1,50 @@
<?php
/**
* Generic <head> for any vertical.
*
* Expects $vertical (a row from application/config/verticals.php).
* Replaces the per-vertical barber-head.php / beauty-head.php, which differ
* only in <title>, og:site_name, og:url, meta description and the stylesheet.
*/
$this->load->helper('url');
?>
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-TWRHH7DN');</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title><?php echo htmlspecialchars($vertical['title'], ENT_QUOTES, 'UTF-8');?></title>
<meta property="fb:app_id" content="285150047390571" />
<meta property="og:site_name" content="<?php echo htmlspecialchars($vertical['og_site_name'], ENT_QUOTES, 'UTF-8');?>">
<meta property="og:image" content="<?php echo SITEURL;?>assets/img/studiobeve_facebook.jpg"/>
<meta property="og:title" content="Studio Beve" />
<meta property="og:description" content="<?php echo htmlspecialchars($vertical['og_description'], ENT_QUOTES, 'UTF-8');?>" />
<meta property="og:url" content="<?php echo SITEURL.$vertical['slug'];?>" />
<meta property="og:type" content="website" />
<meta name="description" content="<?php echo htmlspecialchars($vertical['meta_description'], ENT_QUOTES, 'UTF-8');?>">
<link rel="icon" type="image/x-icon" href="<?php echo base_url();?>assets/img/favicon/favicon.ico" />
<link rel="icon" type="image/png" href="<?php echo base_url();?>assets/img/favicon/favicon.png" />
<link rel="icon" type="image/gif" href="<?php echo base_url();?>assets/img/favicon/favicon.gif" />
<!-- Opera Speed Dial Favicon -->
<link rel="icon" type="image/png" href="<?php echo base_url();?>assets/img/favicon/speeddial-160px.png" />
<!-- For iPhone 4 Retina display: -->
<script src="<?php echo base_url();?>assets/js/jquery/jquery.js"></script>
<link rel="apple-touch-icon-precomposed" sizes="114x114" href="<?php echo base_url();?>assets/img/favicon/apple-touch-icon-114x114-precomposed.png">
<link rel="stylesheet" href="<?php echo base_url();?>assets/css/<?php echo $vertical['css'];?>" />
<noscript><link rel="stylesheet" href="<?php echo base_url();?>assets/css/noscript.css" /></noscript>
<link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Open+Sans:ital,wght@0,700;0,800;1,700;1,800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="<?php echo base_url();?>assets/js/jquery/jquery-ui.css">
<script src="<?php echo base_url();?>assets/js/jquery/jquery-ui.js"></script>
<script src="https://momentjs.com/downloads/moment-with-locales.js"></script>
<!-- Start cookieyes banner --> <script id="cookieyes" type="text/javascript" src="https://cdn-cookieyes.com/client_data/82ccb0b38ad63612e4c6a3fa/script.js"></script> <!-- End cookieyes banner -->
<link rel="stylesheet" type="text/css" href="<?php echo base_url();?>assets/css/style.css">
<link rel="stylesheet" type="text/css" href="<?php echo base_url();?>assets/css/responsive.css">
<script src="<?php echo base_url();?>assets/js/script.js"></script>
</head>
@@ -0,0 +1,59 @@
<?php
/**
* Generic site header for any vertical.
*
* Expects $vertical and $selectedLang.
*
* The per-vertical headers this replaces carried FOUR near-identical copies of
* the same <ul> - one per language plus an unlabelled else-fallback that was
* reached whenever $selectedLang was not en/hu/no (and which referenced
* $currentPageUrlArray before it was defined). Here there is one nav and one
* label table, so the fallback cannot drift from the real branches.
*/
$labels = array(
'en' => array(
'home' => 'Home', 'about' => 'About US', 'treatments' => 'Treatments',
'prices' => 'Prices', 'hours' => 'Opening Hours', 'contact' => 'Contact',
'book' => 'BOOK TIME',
),
'no' => array(
'home' => 'Forside', 'about' => 'Om oss', 'treatments' => 'Behandlinger',
'prices' => 'Priser', 'hours' => 'Åpningstider', 'contact' => 'Kontakt',
'book' => 'BESTILL TIME',
),
'hu' => array(
'home' => 'Kezdőlap', 'about' => 'Rólunk', 'treatments' => 'Szolgáltatások',
'prices' => 'Árlista', 'hours' => 'Nyitvatartás', 'contact' => 'Kapcsolat',
'book' => 'IDŐPONTFOGLALÁS',
),
);
$lang = vertical_lang(isset($selectedLang) ? $selectedLang : 'en');
$t = $labels[$lang];
// Language switcher: rebuild the current URL with a different language segment.
// Segment 1 is the language, 2 and 3 are the page path.
$currentPageUrlArray = explode('/', $_SERVER['REQUEST_URI']);
$currentPageUrlArray += array(1 => '', 2 => '', 3 => '');
?>
<!-- Header -->
<header id="header">
<a href="<?php echo SITEURL;?>"><img src="<?php echo SITEURL;?>assets/img/<?php echo $vertical['logo'];?>" class="logo-kep"></a> <h1><?php echo htmlspecialchars($vertical['heading'], ENT_QUOTES, 'UTF-8');?></h1>
<nav id="nav">
<ul>
<li><a href="<?php echo SITEURL;?>"><?php echo $t['home'];?></a></li>
<li><a href="#aboutus"><?php echo $t['about'];?></a></li>
<li><a href="#treatments"><?php echo $t['treatments'];?></a></li>
<li><a href="#one"><?php echo $t['prices'];?></a></li>
<li><a href="#two"><?php echo $t['hours'];?></a></li>
<li><a href="#footer"><?php echo $t['contact'];?></a></li>
<li class="booktime"><a href="<?php echo SITEURL.$lang.'/booking/'.$vertical['slug'];?>"><?php echo $t['book'];?></a></li>
<li class="socialmedia-menu"><a href="<?php echo $vertical['facebook'];?>" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li class="socialmedia-menu"><a href="<?php echo $vertical['instagram'];?>" class="icon brands fa-instagram"><span class="label">Instagram</span></a></li>
<?php foreach(array('no', 'en', 'hu') as $switchLang): ?>
<li class="language"><a href="<?php echo SITEURL.$switchLang.'/'.$currentPageUrlArray[2].'/'.$currentPageUrlArray[3];?>"><?php echo strtoupper($switchLang);?></a></li>
<?php endforeach; ?>
</ul>
</nav>
</header>
@@ -0,0 +1,15 @@
<?php
include(getcwd().'/application/views/includes/vertical-footer.php');
?>
</div>
</div>
</div>
<script src="<?php echo SITEURL;?>assets/js/jquery.dropotron.min.js"></script>
<script src="<?php echo SITEURL;?>assets/js/browser.min.js"></script>
<script src="<?php echo SITEURL;?>assets/js/breakpoints.min.js"></script>
<script src="<?php echo SITEURL;?>assets/js/util.js"></script>
<script src="<?php echo SITEURL;?>assets/js/studiobave.js"></script>
</body>
</html>
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<?php
include(getcwd().'/application/views/includes/vertical-head.php');
?>
<body class="is-preload">
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TWRHH7DN"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager -->
<!-- Wrapper -->
<div id="wrapper">
<!-- Header -->
<!-- Menu -->
<!-- Main -->
<div id="main">
<?php
include(getcwd().'/application/views/includes/vertical-header.php');
?>
<div class="inner">