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:
@@ -298,8 +298,9 @@ class Admin extends CI_Controller {
|
||||
}
|
||||
|
||||
if(isset($_POST['addNewService'])){
|
||||
$allowedServiceTypes = array('barber', 'beauty', 'massage');
|
||||
$serviceArray = array(
|
||||
'service_type' => $_POST['service_type'],
|
||||
'service_type' => (isset($_POST['service_type']) && in_array($_POST['service_type'], $allowedServiceTypes, true)) ? $_POST['service_type'] : 'beauty',
|
||||
'service_category_no' => $_POST['service_category_no'],
|
||||
'service_category_en' => $_POST['service_category_en'],
|
||||
'service_category_hu' => $_POST['service_category_hu'],
|
||||
@@ -320,8 +321,10 @@ class Admin extends CI_Controller {
|
||||
}
|
||||
|
||||
if(isset($_POST['updateService'])){
|
||||
$allowedServiceTypes = array('barber', 'beauty', 'massage');
|
||||
$existingService = $this->Service_model->getServiceById($_POST['service_id']);
|
||||
$serviceArray = array(
|
||||
'service_type' => $_POST['service_type'],
|
||||
'service_type' => (isset($_POST['service_type']) && in_array($_POST['service_type'], $allowedServiceTypes, true)) ? $_POST['service_type'] : ($existingService ? $existingService->service_type : 'beauty'),
|
||||
'service_category_no' => $_POST['service_category_no'],
|
||||
'service_category_en' => $_POST['service_category_en'],
|
||||
'service_category_hu' => $_POST['service_category_hu'],
|
||||
@@ -542,6 +545,8 @@ class Admin extends CI_Controller {
|
||||
|
||||
$data['serviceCategories'] = $this->Service_model->getAllServiceCategories();
|
||||
$data['selectedItem'] = $this->Service_model->getWorkerById($worker_id);
|
||||
// Verticals are derived from the worker's service category, not stored.
|
||||
$data['workerVerticals'] = $this->Service_model->getVerticalsForWorker($worker_id);
|
||||
$this->load->view('admin/update-worker', $data);
|
||||
}
|
||||
else{
|
||||
@@ -597,11 +602,12 @@ class Admin extends CI_Controller {
|
||||
|
||||
}
|
||||
|
||||
// is_beauty / is_barber are no longer submitted by the form - the
|
||||
// vertical is derived from service_category_id. Service_model
|
||||
// keeps the legacy columns consistent on write.
|
||||
$workerArray = array(
|
||||
'worker_name' => $_POST['worker_name'],
|
||||
'worker_info' => $_POST['worker_info'],
|
||||
'is_beauty' => $_POST['is_beauty'],
|
||||
'is_barber' => $_POST['is_barber'],
|
||||
'worker_profile_img' => $data['worker_img'],
|
||||
'service_category_id' => $_POST['service_category_id'],
|
||||
'is_active' => $_POST['is_active']
|
||||
@@ -615,8 +621,6 @@ class Admin extends CI_Controller {
|
||||
$workerArray = array(
|
||||
'worker_name' => $_POST['worker_name'],
|
||||
'worker_info' => $_POST['worker_info'],
|
||||
'is_beauty' => $_POST['is_beauty'],
|
||||
'is_barber' => $_POST['is_barber'],
|
||||
'service_category_id' => $_POST['service_category_id'],
|
||||
'is_active' => $_POST['is_active'],
|
||||
'google_calendar_id' => isset($_POST['google_calendar_id']) ? $_POST['google_calendar_id'] : '',
|
||||
|
||||
@@ -49,6 +49,31 @@ class Pages extends CI_Controller {
|
||||
$this->load->view('pages/beauty', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic landing page for any registered vertical.
|
||||
*
|
||||
* barber() and beauty() above are kept as-is so their rendered HTML does
|
||||
* not change; new verticals route here instead of gaining a method each.
|
||||
*/
|
||||
public function vertical($lang, $slug){
|
||||
$this->load->helper('url');
|
||||
$this->load->model('User_model');
|
||||
$this->load->model('Service_model');
|
||||
|
||||
$vertical = vertical_get($slug);
|
||||
if(!$vertical){
|
||||
show_404();
|
||||
return;
|
||||
}
|
||||
|
||||
$data['pageTitle'] = '';
|
||||
$data['selectedLang'] = vertical_lang($lang);
|
||||
$data['vertical'] = $vertical;
|
||||
$data['services'] = $this->Service_model->getAllServiceByServiceType($vertical['slug'], $data['selectedLang']);
|
||||
|
||||
$this->load->view($vertical['view_landing'], $data);
|
||||
}
|
||||
|
||||
public function booking($lang, $subpage){
|
||||
$this->load->helper('url');
|
||||
$this->load->model('User_model');
|
||||
@@ -58,19 +83,20 @@ class Pages extends CI_Controller {
|
||||
$data['selectedLang'] = $lang;
|
||||
$data['subpage'] = $subpage;
|
||||
|
||||
// Whitelist the vertical through the registry. An unknown slug used to
|
||||
// fall through the switch default to a bare redirect; now anything not
|
||||
// registered and enabled is rejected the same way, in one place.
|
||||
$vertical = vertical_get($subpage);
|
||||
if(!$vertical){
|
||||
header('Location:'.SITEURL);
|
||||
return;
|
||||
}
|
||||
|
||||
$data['vertical'] = $vertical;
|
||||
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
|
||||
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
|
||||
|
||||
switch($subpage){
|
||||
case 'barber':
|
||||
$this->load->view('pages/barber-booking', $data);
|
||||
break;
|
||||
case 'beauty':
|
||||
$this->load->view('pages/beauty-booking', $data);
|
||||
break;
|
||||
default:
|
||||
header('Location:'.SITEURL);
|
||||
}
|
||||
$this->load->view($vertical['view_booking'], $data);
|
||||
}
|
||||
|
||||
public function booking_finished($lang, $subpage){
|
||||
@@ -80,16 +106,16 @@ class Pages extends CI_Controller {
|
||||
$data['pageTitle'] = '';
|
||||
$data['selectedLang'] = $lang;
|
||||
|
||||
switch($subpage){
|
||||
case 'barber':
|
||||
$this->load->view('pages/barber-booking-finished', $data);
|
||||
break;
|
||||
case 'beauty':
|
||||
$this->load->view('pages/beauty-booking-finished', $data);
|
||||
break;
|
||||
default:
|
||||
$vertical = vertical_get($subpage);
|
||||
if(!$vertical){
|
||||
header('Location:'.SITEURL);
|
||||
return;
|
||||
}
|
||||
|
||||
$data['vertical'] = $vertical;
|
||||
$data['subpage'] = $subpage;
|
||||
|
||||
$this->load->view($vertical['view_finished'], $data);
|
||||
}
|
||||
|
||||
public function email_verification(){
|
||||
@@ -284,24 +310,25 @@ class Pages extends CI_Controller {
|
||||
private function _booking_error($errorCode, $statusCode = 409){
|
||||
$this->load->helper('url');
|
||||
|
||||
$lang = isset($_POST['lang']) && in_array($_POST['lang'], array('no', 'en', 'hu')) ? $_POST['lang'] : 'en';
|
||||
$subpage = isset($_POST['subpage']) && $_POST['subpage'] != '' ? $_POST['subpage'] : 'barber';
|
||||
$lang = vertical_lang(isset($_POST['lang']) ? $_POST['lang'] : '');
|
||||
|
||||
// vertical_get() doubles as the whitelist for the posted subpage:
|
||||
// anything unregistered falls back to barber, as before.
|
||||
$vertical = vertical_get(isset($_POST['subpage']) ? $_POST['subpage'] : '');
|
||||
if(!$vertical){
|
||||
$vertical = vertical_get('barber');
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'pageTitle' => '',
|
||||
'selectedLang' => $lang,
|
||||
'subpage' => $subpage,
|
||||
'subpage' => $vertical['slug'],
|
||||
'vertical' => $vertical,
|
||||
'bookingErrorCode' => $errorCode
|
||||
);
|
||||
|
||||
$this->output->set_status_header($statusCode);
|
||||
|
||||
if($subpage == 'beauty'){
|
||||
$this->load->view('pages/beauty-booking-error', $data);
|
||||
}
|
||||
else{
|
||||
$this->load->view('pages/barber-booking-error', $data);
|
||||
}
|
||||
$this->load->view($vertical['view_error'], $data);
|
||||
}
|
||||
|
||||
public function booking_process(){
|
||||
@@ -379,8 +406,40 @@ class Pages extends CI_Controller {
|
||||
|
||||
$bookingDate = $bookingArray['booking_date'];
|
||||
$bookingTime = $bookingArray['booking_start_time'];
|
||||
$weekday = date('w', strtotime($bookingDate));
|
||||
$schedule = $this->Service_model->getWorkerScheduleByDay($bookingArray['worker_id'], $weekday);
|
||||
|
||||
// Category match: the worker must actually perform EVERY selected service.
|
||||
// Mirrors the guard in manage_booking_process(); server-side equivalent of the
|
||||
// client-side mutual exclusion, so a crafted or replayed POST cannot book a
|
||||
// worker for another vertical's services.
|
||||
$submittedWorker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
|
||||
if (!$submittedWorker) {
|
||||
$this->_booking_error('worker_unavailable', 403);
|
||||
return;
|
||||
}
|
||||
if (empty($selectedServiceArray)) {
|
||||
$this->_booking_error('no_service', 400);
|
||||
return;
|
||||
}
|
||||
foreach ($selectedServiceArray as $selectedServiceId) {
|
||||
$selectedService = $this->Service_model->getServiceById($selectedServiceId);
|
||||
if (!$selectedService || $selectedService->service_category_id != $submittedWorker->service_category_id) {
|
||||
$this->_booking_error('category_mismatch', 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-service verticals: the client disables the other checkboxes,
|
||||
// but that is a UI affordance only. The vertical is derived from the
|
||||
// services themselves rather than $_POST['subpage'], which is
|
||||
// client-supplied.
|
||||
$firstSelectedService = $this->Service_model->getServiceById($selectedServiceArray[0]);
|
||||
$bookingVertical = $firstSelectedService ? vertical_get($firstSelectedService->service_type) : NULL;
|
||||
if (!empty($bookingVertical['single_service_booking']) && count($selectedServiceArray) > 1) {
|
||||
$this->_booking_error('single_service_only', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$schedule = $this->Service_model->getWorkerScheduleForDate($bookingArray['worker_id'], $bookingDate);
|
||||
|
||||
if (!$schedule) {
|
||||
$this->_booking_error('worker_unavailable', 403);
|
||||
@@ -392,7 +451,7 @@ class Pages extends CI_Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->Service_model->isWorkerAvailableThisWeek($bookingArray['worker_id'], $bookingDate)) {
|
||||
if ($schedule->source === 'schedule' && !$this->Service_model->isWorkerAvailableThisWeek($bookingArray['worker_id'], $bookingDate)) {
|
||||
$this->_booking_error('alternate_week', 403);
|
||||
return;
|
||||
}
|
||||
@@ -755,23 +814,37 @@ class Pages extends CI_Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine booking type from services
|
||||
// Determine booking type from services. Scan until one resolves rather
|
||||
// than trusting services[0]: if that single service has since been
|
||||
// hard-deleted, the guest's manage page would silently render with the
|
||||
// wrong vertical's branding.
|
||||
$serviceIds = unserialize($booking->service_ids);
|
||||
$subpage = 'barber';
|
||||
if(is_array($serviceIds) && !empty($serviceIds)){
|
||||
$firstService = $this->Service_model->getServiceById($serviceIds[0]);
|
||||
if($firstService){
|
||||
$subpage = $firstService->service_type;
|
||||
if(is_array($serviceIds)){
|
||||
foreach($serviceIds as $serviceIdItem){
|
||||
$resolvedService = $this->Service_model->getServiceById($serviceIdItem);
|
||||
if($resolvedService && $resolvedService->service_type !== ''){
|
||||
$subpage = $resolvedService->service_type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
|
||||
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
|
||||
|
||||
// A booking's vertical is derived from its services, so an unregistered
|
||||
// or stale service_type must still render something: fall back to barber.
|
||||
$vertical = vertical_get($subpage);
|
||||
if(!$vertical){
|
||||
$vertical = vertical_get('barber');
|
||||
}
|
||||
|
||||
$data['token'] = $token;
|
||||
$data['booking'] = $booking;
|
||||
$data['selectedLang'] = $lang;
|
||||
$data['subpage'] = $subpage;
|
||||
$data['subpage'] = $vertical['slug'];
|
||||
$data['vertical'] = $vertical;
|
||||
$data['pageTitle'] = '';
|
||||
$data['status'] = 'ok';
|
||||
|
||||
@@ -863,8 +936,7 @@ class Pages extends CI_Controller {
|
||||
}
|
||||
|
||||
// Schedule check
|
||||
$weekday = date('w', strtotime($newBookingDate));
|
||||
$schedule = $this->Service_model->getWorkerScheduleByDay($newWorkerId, $weekday);
|
||||
$schedule = $this->Service_model->getWorkerScheduleForDate($newWorkerId, $newBookingDate);
|
||||
if(!$schedule){
|
||||
$this->output->set_status_header(403)->set_content_type('application/json')
|
||||
->set_output(json_encode(['error' => 'Worker is not available on this day.']));
|
||||
|
||||
Reference in New Issue
Block a user