diff --git a/application/config/autoload.php b/application/config/autoload.php
index dd4098c..0da0117 100755
--- a/application/config/autoload.php
+++ b/application/config/autoload.php
@@ -89,7 +89,7 @@ $autoload['drivers'] = array();
|
| $autoload['helper'] = array('url', 'file');
*/
-$autoload['helper'] = array();
+$autoload['helper'] = array('vertical');
/*
| -------------------------------------------------------------------
diff --git a/application/config/routes.php b/application/config/routes.php
index 118875b..67abb23 100755
--- a/application/config/routes.php
+++ b/application/config/routes.php
@@ -60,6 +60,11 @@ $route['profile'] = 'pages/profile';
$route['log-monitor'] = 'Pages/log_monitor';
$route['(:any)/barber'] = 'pages/barber/$1';
$route['(:any)/beauty'] = 'pages/beauty/$1';
+// Verticals added after the registry was introduced dispatch through
+// Pages::vertical(), which resolves branding and views from
+// application/config/verticals.php. barber and beauty keep their original
+// dedicated routes so their output stays byte-identical.
+$route['(:any)/massage'] = 'pages/vertical/$1/massage';
$route['(:any)/booking/(:any)'] = 'pages/booking/$1/$2';
$route['(:any)/booking-finished/(:any)'] = 'pages/booking_finished/$1/$2';
$route['email-verification'] = 'pages/email_verification';
diff --git a/application/config/verticals.php b/application/config/verticals.php
new file mode 100644
index 0000000..1a3edc7
--- /dev/null
+++ b/application/config/verticals.php
@@ -0,0 +1,168 @@
+.
+| heading the "- BEAUTY" text beside the logo in the header.
+| category_slug service_categories.serv_cat_slug that this vertical's
+| workers belong to. Informational: the runtime derives the
+| worker set from the DB, never from this value.
+|
+| ONE SLUG ACROSS ALL LANGUAGES
+| -----------------------------
+| The language switcher rebuilds URLs by exploding $_SERVER['REQUEST_URI'] and
+| reusing segment 2 verbatim (see *-header.php). A per-language slug would
+| therefore produce a 404 the moment a visitor switched language. Do not add
+| one. If a localised URL is ever needed, add an alias route pointing at the
+| same canonical slug.
+|
+| Note also config.php permitted_uri_chars excludes æ/ø/å, so a Norwegian
+| slug containing them would be rejected by CodeIgniter before routing.
+*/
+
+$config['verticals'] = array(
+
+ 'barber' => array(
+ 'slug' => 'barber',
+ 'enabled' => TRUE,
+ 'public' => TRUE,
+ 'sort' => 10,
+ 'category_slug' => 'cosmeric',
+
+ 'label' => array('no' => 'BARBER', 'en' => 'BARBER', 'hu' => 'BARBER'),
+ 'heading' => '- BARBER',
+
+ 'title' => 'Studio Beve - Beauty & Barber Shop',
+ 'og_site_name' => 'Studio Beve Barber',
+ 'og_description' => 'Vippestylist, make up artist, PMU-artist',
+ 'meta_description' => 'Skjeggtrimming, Barneklipp, Skjeggfarging, Herreklipp, Skinfade Herreklipp, Voks, Beard Trimming, Child Haircut, Beard Dying, Traditional Shave, Men\'s Haircut, Skinfade, Wax, herrefrisør, Trondheim, hårklipp, haircut, barber, barberer, Men\'s hairdresser',
+
+ 'css' => 'barber.css',
+ 'logo' => 'logo_gold.png',
+ 'img_dir' => 'barber',
+ 'map_img' => 'assets/img/barber/map.jpg',
+
+ 'facebook' => 'https://www.facebook.com/studiobevebarber',
+ 'instagram' => 'https://www.instagram.com/studio.beve.barber/',
+
+ 'tile_class' => 'style2',
+ 'tile_img' => 'assets/img/barber.jpg',
+ // The in the home tile is only a size spacer; the visible art is
+ // the CSS .image:before background. Kept per-vertical so the existing
+ // tiles render exactly as before.
+ 'tile_spacer' => 'assets/img/pic03.jpg',
+
+ // Strangler: existing dedicated files, untouched.
+ 'skeleton' => 'barber',
+ 'view_landing' => 'pages/barber',
+ 'view_booking' => 'pages/barber-booking',
+ 'view_finished' => 'pages/barber-booking-finished',
+ 'view_error' => 'pages/barber-booking-error',
+ ),
+
+ 'beauty' => array(
+ 'slug' => 'beauty',
+ 'enabled' => TRUE,
+ 'public' => TRUE,
+ 'sort' => 20,
+ 'category_slug' => 'cosmeric',
+
+ 'label' => array('no' => 'BEAUTY', 'en' => 'BEAUTY', 'hu' => 'BEAUTY'),
+ 'heading' => '- BEAUTY',
+
+ 'title' => 'Studio Beve - Beauty',
+ 'og_site_name' => 'Studio Beve',
+ 'og_description' => 'Vippestylist, make up artist, PMU-artist',
+ 'meta_description' => 'Voksing, Sminke, Vippeextensions, Pudderbryn, PMU-Tatovering, Øyenbrynstyling, Vippestyling, Powder Brows, permanent, PMU Makeup, Makeup, Wax, Eyelash Extension, Eyebrow styling, Eyelash Styling, Wedding makeup, Brow lamination, Lash dying, Brow forming, Henna dying, Lash lift, Naturlig sminke, Bryllupssminke, Laminering, Henna farging, Vippeløft, Forming av bryn, Farging av vipper, Trondheim',
+
+ 'css' => 'beauty.css',
+ 'logo' => 'logo.png',
+ 'img_dir' => 'beauty',
+ 'map_img' => 'assets/img/beauty/map.jpg',
+
+ 'facebook' => 'https://www.facebook.com/studiobevebeauty',
+ 'instagram' => 'https://www.instagram.com/studio.beve.beauty/',
+
+ 'tile_class' => 'style1',
+ 'tile_img' => 'assets/img/beauty.jpg',
+ 'tile_spacer' => 'assets/img/pic09.jpg',
+
+ // Strangler: existing dedicated files, untouched.
+ 'skeleton' => 'beauty',
+ 'view_landing' => 'pages/beauty',
+ 'view_booking' => 'pages/beauty-booking',
+ 'view_finished' => 'pages/beauty-booking-finished',
+ 'view_error' => 'pages/beauty-booking-error',
+ ),
+
+ 'massage' => array(
+ 'slug' => 'massage',
+ 'enabled' => TRUE,
+ // Launch switch: the tile CSS now supports three tiles, so massage is live.
+ 'public' => TRUE,
+ 'sort' => 30,
+ 'category_slug' => 'massage',
+ // Row labels in grouped service lists drop the category prefix, so a
+ // heading of "Klassisk svensk massasje" is followed by "50 min" rather
+ // than the whole name again. barber/beauty omit this and are unchanged.
+ 'strip_category_prefix' => TRUE,
+ // A guest books one massage at a time: selecting a treatment clears and
+ // disables the others. Enforced in the UI *and* in booking_process().
+ // barber/beauty omit this and keep multi-select.
+ 'single_service_booking' => TRUE,
+
+ 'label' => array('no' => 'MASSASJE', 'en' => 'MASSAGE', 'hu' => 'MASSZÁZS'),
+ 'heading' => '- MASSAGE',
+
+ 'title' => 'Studio Beve - Massage',
+ 'og_site_name' => 'Studio Beve Massage',
+ 'og_description' => 'Massasje, klassisk massasje, dyp vevsmassasje',
+ 'meta_description' => 'Massasje, Klassisk massasje, Dypvevsmassasje, Ryggmassasje, Nakkemassasje, Idrettsmassasje, Avslappende massasje, Massage, Deep tissue massage, Sports massage, Relaxing massage, Back massage, Masszázs, Svédmasszázs, Frissítő masszázs, Trondheim',
+
+ 'css' => 'massage.css',
+ 'logo' => 'logo.png',
+ 'img_dir' => 'massage',
+ 'map_img' => 'assets/img/massage/map.jpg',
+
+ 'facebook' => 'https://www.facebook.com/studiobevebeauty',
+ 'instagram' => 'https://www.instagram.com/studio.beve.beauty/',
+
+ 'tile_class' => 'style3',
+ 'tile_img' => 'assets/img/massage.jpg',
+ 'tile_spacer' => 'assets/img/pic03.jpg',
+
+ // The generic parameterised views.
+ 'skeleton' => 'vertical',
+ 'view_landing' => 'pages/vertical-landing',
+ 'view_booking' => 'pages/vertical-booking',
+ 'view_finished' => 'pages/vertical-booking-finished',
+ 'view_error' => 'pages/vertical-booking-error',
+ ),
+
+);
diff --git a/application/controllers/Admin.php b/application/controllers/Admin.php
index 5f6dee0..8db679c 100755
--- a/application/controllers/Admin.php
+++ b/application/controllers/Admin.php
@@ -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'] : '',
diff --git a/application/controllers/Pages.php b/application/controllers/Pages.php
index 82a0c43..cc8d946 100755
--- a/application/controllers/Pages.php
+++ b/application/controllers/Pages.php
@@ -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.']));
diff --git a/application/helpers/vertical_helper.php b/application/helpers/vertical_helper.php
new file mode 100644
index 0000000..8590bbe
--- /dev/null
+++ b/application/helpers/vertical_helper.php
@@ -0,0 +1,239 @@
+config->load('verticals', FALSE, TRUE);
+ $verticals = $CI->config->item('verticals');
+
+ return is_array($verticals) ? $verticals : array();
+ }
+}
+
+if ( ! function_exists('vertical_get'))
+{
+ /**
+ * One vertical by slug, or NULL when the slug is unknown or disabled.
+ *
+ * Use this as the whitelist for any user-supplied vertical value.
+ *
+ * @param string $slug
+ * @return array|null
+ */
+ function vertical_get($slug)
+ {
+ if ( ! is_string($slug) || $slug === '')
+ {
+ return NULL;
+ }
+
+ $verticals = vertical_all();
+
+ if ( ! isset($verticals[$slug]))
+ {
+ return NULL;
+ }
+
+ if (empty($verticals[$slug]['enabled']))
+ {
+ return NULL;
+ }
+
+ return $verticals[$slug];
+ }
+}
+
+if ( ! function_exists('vertical_exists'))
+{
+ /**
+ * @param string $slug
+ * @return bool
+ */
+ function vertical_exists($slug)
+ {
+ return vertical_get($slug) !== NULL;
+ }
+}
+
+if ( ! function_exists('vertical_slugs'))
+{
+ /**
+ * Slugs of every enabled vertical. Suitable for in_array() checks and for
+ * building the admin service_type dropdown.
+ *
+ * @return array
+ */
+ function vertical_slugs()
+ {
+ $slugs = array();
+
+ foreach (vertical_all() as $slug => $vertical)
+ {
+ if ( ! empty($vertical['enabled']))
+ {
+ $slugs[] = $slug;
+ }
+ }
+
+ return $slugs;
+ }
+}
+
+if ( ! function_exists('vertical_public'))
+{
+ /**
+ * Enabled verticals flagged public, ordered by 'sort'. This is what the
+ * home page tiles iterate over — flipping 'public' is the launch switch.
+ *
+ * @return array
+ */
+ function vertical_public()
+ {
+ $public = array();
+
+ foreach (vertical_all() as $slug => $vertical)
+ {
+ if ( ! empty($vertical['enabled']) && ! empty($vertical['public']))
+ {
+ $public[$slug] = $vertical;
+ }
+ }
+
+ uasort($public, 'vertical_sort_compare');
+
+ return $public;
+ }
+}
+
+if ( ! function_exists('vertical_sort_compare'))
+{
+ /**
+ * uasort() callback. Named rather than a closure so this helper stays
+ * compatible with the PHP version the live host runs.
+ *
+ * @param array $a
+ * @param array $b
+ * @return int
+ */
+ function vertical_sort_compare($a, $b)
+ {
+ $sa = isset($a['sort']) ? (int) $a['sort'] : 0;
+ $sb = isset($b['sort']) ? (int) $b['sort'] : 0;
+
+ if ($sa === $sb)
+ {
+ return 0;
+ }
+
+ return ($sa < $sb) ? -1 : 1;
+ }
+}
+
+if ( ! function_exists('vertical_label'))
+{
+ /**
+ * Display label for a vertical in the given language, falling back to
+ * English and then to the uppercased slug.
+ *
+ * @param array $vertical
+ * @param string $lang
+ * @return string
+ */
+ function vertical_label($vertical, $lang)
+ {
+ if (isset($vertical['label'][$lang]))
+ {
+ return $vertical['label'][$lang];
+ }
+
+ if (isset($vertical['label']['en']))
+ {
+ return $vertical['label']['en'];
+ }
+
+ return isset($vertical['slug']) ? strtoupper($vertical['slug']) : '';
+ }
+}
+
+if ( ! function_exists('vertical_lang'))
+{
+ /**
+ * Whitelist a language segment. The site serves no/en/hu; anything else
+ * falls back to English, matching the existing per-view $langArray checks.
+ *
+ * @param string $lang
+ * @return string
+ */
+ function vertical_lang($lang)
+ {
+ $allowed = array('no', 'en', 'hu');
+
+ return (is_string($lang) && in_array($lang, $allowed, TRUE)) ? $lang : 'en';
+ }
+}
+
+if ( ! function_exists('vertical_service_label'))
+{
+ /**
+ * Display label for a service in a list that is ALREADY grouped under its
+ * category heading.
+ *
+ * services.service_name has to stay self-describing - the confirmation email
+ * and the admin lists render it with no category around it. But in the
+ * booking form, the manage-booking form and the landing-page price list the
+ * category heading sits directly above the row, so repeating it makes every
+ * row wrap and the list read as cramped:
+ *
+ * [Studio Beve Signature - individuelt tilpasset massasje]
+ * Studio Beve Signature - individuelt tilpasset massasje - 50 min
+ *
+ * Opt in per vertical via 'strip_category_prefix' so barber and beauty keep
+ * rendering exactly as before.
+ *
+ * @param array $vertical registry row (may be NULL or missing the key)
+ * @param object $serviceItem needs ->service_name and ->service_category
+ * @return string
+ */
+ function vertical_service_label($vertical, $serviceItem)
+ {
+ $name = isset($serviceItem->service_name) ? $serviceItem->service_name : '';
+
+ if (empty($vertical['strip_category_prefix']))
+ {
+ return $name;
+ }
+
+ $category = isset($serviceItem->service_category) ? $serviceItem->service_category : '';
+
+ if ($category === '' OR strpos($name, $category) !== 0)
+ {
+ return $name;
+ }
+
+ // The /u modifier matters: the separator is an en dash, and a byte-wise
+ // trim() would only mangle it.
+ $stripped = substr($name, strlen($category));
+ $stripped = preg_replace('/^[\s\-–—]+/u', '', $stripped);
+ $stripped = trim($stripped);
+
+ return ($stripped !== '') ? $stripped : $name;
+ }
+}
diff --git a/application/models/Service_model.php b/application/models/Service_model.php
index 62127dc..639fc1e 100755
--- a/application/models/Service_model.php
+++ b/application/models/Service_model.php
@@ -63,8 +63,70 @@ class Service_model extends CI_Model {
$this->db->update('service_categories', $serviceCategoryArray);
}
+ /**
+ * The verticals (service_type values) a service category covers.
+ *
+ * This is the single source of truth for "what does this worker do".
+ *
+ * @param int $service_category_id
+ * @return array e.g. array('barber', 'beauty')
+ */
+ public function getVerticalsForCategory($service_category_id){
+ $this->load->database();
+ $query = $this->db->query('
+ SELECT DISTINCT service_type
+ FROM services
+ WHERE service_category_id = ?
+ AND is_deleted != "1"
+ ORDER BY service_type ASC;', array($service_category_id));
+
+ $types = array();
+ foreach($query->result() as $row){
+ $types[] = $row->service_type;
+ }
+ return $types;
+ }
+
+ /**
+ * The verticals a given worker covers, derived from their category.
+ *
+ * @param int $worker_id
+ * @return array
+ */
+ public function getVerticalsForWorker($worker_id){
+ $this->load->database();
+ $worker = $this->getWorkerById($worker_id);
+
+ if(!$worker){
+ return array();
+ }
+
+ return $this->getVerticalsForCategory($worker->service_category_id);
+ }
+
+ /**
+ * Keep the legacy is_beauty / is_barber columns consistent with the
+ * category. NOTHING reads them any more - the vertical is derived (see
+ * getActiveWorkers). They are still NOT NULL, and writing a correct value
+ * means a code rollback cannot strand a newly created worker as invisible.
+ * Derived here rather than trusted from $_POST so the two cannot drift.
+ *
+ * @param array $workerArray by reference; keys are added
+ * @param int $service_category_id
+ * @return void
+ */
+ private function _applyLegacyVerticalFlags(&$workerArray, $service_category_id){
+ $verticals = $this->getVerticalsForCategory($service_category_id);
+
+ $workerArray['is_beauty'] = in_array('beauty', $verticals, TRUE) ? 1 : 0;
+ $workerArray['is_barber'] = in_array('barber', $verticals, TRUE) ? 1 : 0;
+ }
+
public function createWorker($workerArray){
$this->load->database();
+
+ $this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
+
$query = $this->db->query('INSERT INTO workers (
worker_name,
worker_profile_img,
@@ -84,6 +146,13 @@ class Service_model extends CI_Model {
public function updateWorker($worker_id, $workerArray){
$this->load->database();
+
+ // Re-derive the legacy flags whenever the category is being written,
+ // so moving a worker between categories cannot leave them stale.
+ if(isset($workerArray['service_category_id'])){
+ $this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
+ }
+
$this->db->where('worker_id', $worker_id);
$this->db->update('workers', $workerArray);
}
@@ -206,7 +275,28 @@ class Service_model extends CI_Model {
public function getAllWorkers(){
$this->load->database();
- $query = $this->db->query('SELECT * FROM workers JOIN service_categories ON workers.service_category_id = service_categories.service_category_id WHERE workers.is_deleted != "1" ORDER BY worker_name ASC;');
+
+ // LEFT JOIN, not INNER: with an inner join a worker whose category row
+ // is missing or soft-deleted vanishes from the admin list entirely
+ // rather than showing up as broken. Silent disappearance is worse.
+ //
+ // derived_verticals is computed here (one subquery, not N+1) so the
+ // list can show what a worker actually does without consulting the
+ // legacy is_barber / is_beauty columns.
+ $query = $this->db->query('
+ SELECT w.*,
+ sc.serv_cat_name,
+ sc.serv_cat_slug,
+ (SELECT GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR ", ")
+ FROM services s
+ WHERE s.service_category_id = w.service_category_id
+ AND s.is_deleted != "1") AS derived_verticals
+ FROM workers w
+ LEFT JOIN service_categories sc
+ ON sc.service_category_id = w.service_category_id
+ WHERE w.is_deleted != "1"
+ ORDER BY w.worker_name ASC;');
+
return $query->result();
}
@@ -216,21 +306,52 @@ class Service_model extends CI_Model {
return $query->result();
}
+ /**
+ * Active workers, optionally restricted to one vertical.
+ *
+ * The vertical is DERIVED from the data that actually governs booking: a
+ * worker performs a vertical iff at least one live service of that
+ * service_type shares the worker's service_category_id.
+ *
+ * This replaces the old workers.is_barber / workers.is_beauty flags, which
+ * were a hand-maintained cache of exactly this fact and free to drift from
+ * it. Verified against production data before the switch: the derived set
+ * reproduced the stored flags exactly, for every worker, in both verticals.
+ * A new vertical therefore needs no schema change and no new flag column.
+ *
+ * Also removes the last string-concatenated WHERE fragment in this model -
+ * the obvious "fix" for a third vertical was ' AND is_'.$subpage.' = "1"',
+ * which would have turned this into an injection sink reachable from an
+ * unauthenticated POST.
+ *
+ * @param string $subpage vertical slug, or '' for every active worker
+ * @return array
+ */
public function getActiveWorkers($subpage = ''){
$this->load->database();
- $subpageString = '';
- if($subpage == 'beauty'){
- $subpageString = ' AND is_beauty = "1"';
- }
- else if($subpage == 'barber'){
- $subpageString = ' AND is_barber = "1"';
- }
- else{
- $subpageString = '';
+ // No-argument callers (Admin bookings filter, booking calendar) want
+ // every active worker regardless of vertical. Preserve that exactly.
+ if($subpage === '' || $subpage === NULL){
+ $query = $this->db->query('SELECT * FROM workers WHERE is_active = "1" AND is_deleted != "1" ORDER BY worker_name ASC;');
+ return $query->result();
}
- $query = $this->db->query('SELECT * FROM workers WHERE is_active = "1" '.$subpageString.' AND is_deleted != "1" ORDER BY worker_name ASC;');
+ // EXISTS rather than JOIN + DISTINCT: a worker with several matching
+ // services must still appear once, and workers.worker_info is TEXT,
+ // which DISTINCT would have to de-duplicate needlessly.
+ $query = $this->db->query('
+ SELECT * FROM workers w
+ WHERE w.is_active = "1"
+ AND w.is_deleted != "1"
+ AND EXISTS (
+ SELECT 1 FROM services s
+ WHERE s.service_category_id = w.service_category_id
+ AND s.service_type = ?
+ AND s.is_deleted != "1"
+ )
+ ORDER BY w.worker_name ASC;', array($subpage));
+
return $query->result();
}
@@ -255,6 +376,20 @@ class Service_model extends CI_Model {
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
+
+ // A hard-deleted service leaves older bookings pointing at a
+ // row that no longer exists. Render a visible placeholder
+ // rather than dereferencing null and blanking the row.
+ if(!$selectedService){
+ $services[] = (object)array(
+ 'service_type' => '',
+ 'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
+ 'service_price' => 0,
+ 'service_time' => '00:00:00'
+ );
+ continue;
+ }
+
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
@@ -299,6 +434,20 @@ class Service_model extends CI_Model {
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
+
+ // A hard-deleted service leaves older bookings pointing at a
+ // row that no longer exists. Render a visible placeholder
+ // rather than dereferencing null and blanking the row.
+ if(!$selectedService){
+ $services[] = (object)array(
+ 'service_type' => '',
+ 'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
+ 'service_price' => 0,
+ 'service_time' => '00:00:00'
+ );
+ continue;
+ }
+
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
@@ -343,6 +492,20 @@ class Service_model extends CI_Model {
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
+
+ // A hard-deleted service leaves older bookings pointing at a
+ // row that no longer exists. Render a visible placeholder
+ // rather than dereferencing null and blanking the row.
+ if(!$selectedService){
+ $services[] = (object)array(
+ 'service_type' => '',
+ 'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
+ 'service_price' => 0,
+ 'service_time' => '00:00:00'
+ );
+ continue;
+ }
+
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
@@ -796,6 +959,68 @@ class Service_model extends CI_Model {
return $query->row(); // null if no row
}
+ /**
+ * Date-aware schedule lookup. Mirrors the resolution order inside
+ * getAvailableTimes() exactly: an override for the date wins outright,
+ * otherwise fall back to worker_schedule honouring is_alternate_week.
+ *
+ * getWorkerScheduleByDay() above ignores both overrides and alternate
+ * weeks, so using it as a booking guard rejects slots the availability
+ * UI legitimately offered. Prefer this method for guards.
+ *
+ * @return object|null ->start_time, ->end_time, ->source ('override'|'schedule')
+ */
+ public function getWorkerScheduleForDate($worker_id, $selectedDate) {
+ $this->load->database();
+
+ $overrideQuery = $this->db->query("
+ SELECT * FROM worker_schedule_overrides
+ WHERE worker_id = ? AND date = ?
+ LIMIT 1
+ ", array($worker_id, $selectedDate));
+
+ if ($overrideQuery->num_rows() > 0) {
+ $override = $overrideQuery->row();
+
+ if ($override->is_day_off) {
+ return null;
+ }
+ if ($override->start_time === null || $override->end_time === null) {
+ return null;
+ }
+
+ return (object) array(
+ 'start_time' => $override->start_time,
+ 'end_time' => $override->end_time,
+ 'is_alternate_week' => 0,
+ 'source' => 'override',
+ );
+ }
+
+ $weekday = date('w', strtotime($selectedDate));
+ $weekNumber = date('W', strtotime($selectedDate));
+ $isOddWeek = $weekNumber % 2 !== 0;
+
+ $scheduleQuery = $this->db->query("
+ SELECT * FROM worker_schedule
+ WHERE worker_id = ?
+ AND weekday = ?
+ AND (
+ is_alternate_week = 0
+ OR (is_alternate_week = 1 AND " . ($isOddWeek ? "1" : "0") . ")
+ )
+ LIMIT 1
+ ", array($worker_id, $weekday));
+
+ if ($scheduleQuery->num_rows() == 0) {
+ return null;
+ }
+
+ $schedule = $scheduleQuery->row();
+ $schedule->source = 'schedule';
+ return $schedule;
+ }
+
public function isEvenWeek($date) {
return ((int)date('W', strtotime($date)) % 2) === 0;
}
diff --git a/application/views/admin/includes/add-service-form.php b/application/views/admin/includes/add-service-form.php
index 4907fef..ab8224b 100755
--- a/application/views/admin/includes/add-service-form.php
+++ b/application/views/admin/includes/add-service-form.php
@@ -7,6 +7,7 @@