Files
studiobeve.no/application/views/pages/includes/booking-form-no.php
T
UbuntuandClaude Opus 5 5c30467fbd 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>
2026-08-13 16:34:47 +00:00

549 lines
22 KiB
PHP
Executable File

<div class="bookingContainer">
<div class="bookingTitle">Bestillingsprosess</div>
<ul class="steps-indicator steps-3">
<li>
<div class="stepTitleContainer">
<div class="stepLine"></div>
<div class="stepCircle activeStep" id="stepCircle1"></div>
<div class="stepTitle">STEG 1</div>
</div>
</li>
<li>
<div class="stepTitleContainer">
<div class="stepLine"></div>
<div class="stepCircle" id="stepCircle2"></div>
<div class="stepTitle">STEG 2</div>
</div>
</li>
<li>
<div class="stepTitleContainer">
<div class="stepCircle" id="stepCircle3"></div>
<div class="stepTitle">STEG 3</div>
</div>
<li>
</ul>
<div class="totalPanelContainer">
<div id="totalPanel">
<div class="serviceBookingDateContainer">
<div id="serviceBookingDate"></div>
<div id="serviceBookingTime"></div>
</div>
<div class="serviceBookingWorkerContainer">
</div>
<div class="serviceContent">
</div>
<div class="totalSelectedTime"></div>
<div class="totalPrice"></div>
</div>
<div id="totalPanelBtnContainer">
<div id="totalPanelBtn"><i class="fas fa-caret-right"></i></div>
</div>
</div>
<form method="post" action="<?php echo SITEURL;?>booking-process">
<div class="BookingStepContainer" id="bookingStep1">
<div class="totalTime"><input type="time" id="servicelength" name="servicelength" value="00:00"></div>
<div class="bookingTable">
<div class="panel panel-primary">
<div class="panel-heading">Services</div>
</div>
</div>
<div class="serviceTable">
<div class="serviceHeader">
<div class="serviceCol serviceNameCol">Tjenestenavn</div>
<div class="serviceCol"></div>
<div class="serviceCol servicePriceCol">Pris</div>
<div class="serviceCol serviceInfoCol"></div>
<div class="serviceCol serviceCheckCol"></div>
</div>
<?php
function canon_cat($s) {
$s = html_entity_decode((string)$s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$s = strip_tags($s); // removes "<p class=" junk etc.
return trim($s); // removes trailing/leading spaces
}
if (is_array($services) && count($services) > 0) {
// Build groups in first-seen order
$groups = []; // label => [items]
$order = []; // labels in order of first appearance
foreach ($services as $serviceItem) {
$label = canon_cat($serviceItem->service_category ?? '');
if ($label === '') $label = 'Other';
if (!isset($groups[$label])) {
$groups[$label] = [];
$order[] = $label;
}
$groups[$label][] = $serviceItem;
}
// Render
foreach ($order as $label) {
?>
<div class="serviceRow">
<div class="serviceCol" style="width: 100%">
<h4><?php echo $label; ?></h4>
</div>
</div>
<?php
foreach ($groups[$label] as $serviceItem) {
?>
<div class="serviceRow">
<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">
<input type="time" class="serviceTime" id="time_<?php echo $serviceItem->service_id; ?>" value="<?php echo $serviceItem->service_time; ?>"/>
</div>
<div class="serviceCol servicePriceCol" id="servicePrice_<?php echo $serviceItem->service_id; ?>">
<?php echo $serviceItem->service_price; ?>
</div>
<div class="serviceCol serviceInfoCol">
<div class="serviceInfoIcon">
<i class="fas fa-info-circle"></i>
<div class="serviceInfoLabel"><?php echo $serviceItem->service_description; ?></div>
</div>
</div>
<div class="serviceCol serviceCheckCol">
<input type="hidden" name="service_<?php echo $serviceItem->service_id; ?>" value="0"/>
<input type="checkbox"
class="service <?php echo $serviceItem->serv_cat_slug; ?>"
id="<?php echo $serviceItem->service_id; ?>"
name="service_<?php echo $serviceItem->service_id; ?>"
value="1"/>
</div>
</div>
<?php
}
}
}
?>
</div>
<div class="bookingButtonContainer">
<input type="button" class="rightButton bookingButton" onclick="goToStep(2);" value="Neste"/>
</div>
</div>
<div class="BookingStepContainer" id="bookingStep2">
<input type="hidden" name="worker_id" id="worker_id" value="">
<input type="hidden" name="subpage" id="subpage" value="<?php echo $subpage;?>">
<input type="hidden" name="lang" id="lang" value="<?php echo $selectedLang;?>">
<div class="bookingHeader">Velg en arbeider</div>
<div class="workerTable" id="workerTable">
</div>
<div class="bookingButtonContainer">
<input type="button" class="leftButton bookingButton" onclick="goToStep(1);" value="Tilbake"/>
<input type="button" class="rightButton bookingButton" onclick="goToStep(3);" value="Neste"/>
</div>
</div>
<div class="BookingStepContainer" id="bookingStep3">
<div class="bookingCalendar">
<div id="datepicker"></div>
</div>
<div class="bookingResultsWrapper">
<div class="availableTimesContainerTitle"></div>
<div class="availableTimesContainer" style="padding:5px;">
<input type="hidden" name="booking_date" id="booking_date" value="<?php echo date('Y-m-d');?>">
<input type="hidden" name="booking_start_time" id="booking_time" value="">
<div class="availableTimes">
</div>
</div>
</div>
<div class="bookingButtonContainer">
<input type="button" class="leftButton bookingButton" onclick="goToStep(2);" value="Tilbake"/>
<input type="button" class="rightButton bookingButton" id="isTimeSelectedBtn" onclick="goToStep(4);" value="Neste" disabled/>
</div>
</div>
<div class="BookingStepContainer" id="bookingStep4">
<div class="formRow">
<label class="bookingEmailTitle">Navn:</label>
<input type="text" class="bookingName" name="guest_name" value="" required>
</div>
<div class="formRow">
<label class="bookingEmailTitle">E-mail:</label>
<input type="text" class="bookingEmail" name="guest_email" value="" required>
</div>
<div class="formRow">
<label class="bookingPhoneTitle">Telefon:</label>
<input type="text" class="bookingPhone" name="guest_phone" value="" required>
</div>
<div class="bookingButtonContainer">
<input type="button" class="leftButton bookingButton" onclick="goToStep(3);" value="Tilbake"/>
<input type="hidden" name="subpage" value="<?php echo $subpage;?>">
<input type="hidden" name="sendBooking" value="1">
<input type="submit" id="sendBooking" class="rightButton bookingSubmit" for="bookingStepForm" name="sendBooking" value="Sende"/>
</div>
</div>
</form>
</div>
<script>
$(document).ready(function(){
// Prevent double submission. The booking POST stays open for several
// seconds (Google Calendar + confirmation e-mail), so an impatient
// second tap used to hit the server as a separate booking attempt.
var bookingSubmitInProgress = false;
$('#sendBooking').closest('form').on('submit', function(e){
if(bookingSubmitInProgress){
e.preventDefault();
return false;
}
bookingSubmitInProgress = true;
$('#sendBooking').prop('disabled', true).val('Sender...');
});
$('#booking_time').change(function(){
if($('#booking_time').val() != ''){
$('#isTimeSelectedBtn').prop('disabled', false);
}
});
$('#totalPanelBtn').click(function(){
if($('#totalPanel').is(':visible')){
$('#totalPanelBtn').html('<i class="fas fa-caret-left"></i>');
$('#totalPanel').hide();
$('#totalPanelBtn').css('border-top-left-radius','5px');
$('#totalPanelBtn').css('border-bottom-left-radius','5px');
}
else{
$('#totalPanelBtn').html('<i class="fas fa-caret-right"></i>');
$('#totalPanel').show();
$('#totalPanelBtn').css('border-top-left-radius','0px');
$('#totalPanelBtn').css('border-bottom-left-radius','0px');
}
});
<?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">';
selectedServices += '<div class="totalServiceRow serviceHeader"><div class="selectedServiceCol totalSelectedServiceName">Service name</div><div class="selectedServiceCol totalSelectedServicePrice">Price</div><div class="selectedServiceCol serviceInfoCol"></div><div class="selectedServiceCol serviceCheckCol"></div></div>';
var classIsChecked = 0;
$('#servicelength').val('00:00');
$('.service').each(function(i, obj) {
if($(this).is(':checked')){
classIsChecked = 1;
var selected_id = $(this).attr('id');
selectedPriceElement = document.getElementById('servicePrice_'+selected_id).innerHTML;
totalPrice = totalPrice + parseInt(selectedPriceElement);
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();
const selectedServiceDatetime = new Date('<?php echo date('Y-m-d');?> ' + $('#time_'+selected_id).val());
var subTotalTimeHours = moment.utc(selectedTotalTime,'HH:mm').add(selectedServiceDatetime.getHours(),'hour').format('HH:mm');
var subTotalTimeMinutes = moment.utc(subTotalTimeHours,'HH:mm').add(selectedServiceDatetime.getMinutes(),'minutes').format('HH:mm');
//console.log(subTotalTimeMinutes);
$('#servicelength').val(subTotalTimeMinutes);
}
});
selectedServices +='</div></div></div>';
$('.serviceContent').html(selectedServices);
$('.totalPrice').html('<span class="totalTitle">Total:</span> <span>' + totalPrice + '</span><span class="totalTitle"> kr</span>');
$('.totalSelectedTime').html('<span class="totalTitle">Total time:</span> <span>' + $('#servicelength').val() + '</span>');
if(classIsChecked){
$('#totalPanelBtn').show();
$('#totalPanel').show();
}
else{
$('#totalPanelBtn').hide();
$('#totalPanel').hide();
}
});
$(".workers").each(function() {
var worker = $(this).attr('id');
worker_id = worker.split("_");
setWorker(worker_id[1])
});
$('.service').click(function(){
if($('.service').is(':checked')){
var selectedClasses = $(this).attr('class');
var selectedclassesArray = selectedClasses.split(" ");
$('.service').each(function(i, obj) {
var actualElement = $(this).attr('class');
var actualElementArray = actualElement.split(" ");
if(selectedclassesArray[1] !== actualElementArray[1]){
$(this).attr("disabled", true);
}
});
getAvailableWorkersByServiceCategorySlug(selectedclassesArray[1]);
}
else{
$('.service').attr("disabled", false);
}
});
});
function recaptcha_callback(){
selectedDate = $('#booking_date').val();
selectedTime = $('#booking_time').val();
var response = 0;
$.ajax({
url: '<?php echo base_url();?>ajax',
type: 'POST',
data: {
action:'isTimeAvailable',
worker_id:$('#worker_id').val(),
servicelength:$('#servicelength').val(),
selectedDate:selectedDate,
selectedTime:selectedTime
},
error: function() {
},
//dataType: 'json',
success: function(data) {
},
}).done(function(result){
if(result == '1'){
$('#sendBooking').prop("disabled", false);
}
else{
alert('Den valgte tiden er allerede booket, velg et annet tidspunkt!');
}
});
}
function getAvailableWorkersByServiceCategorySlug(categorySlug){
$.ajax({
url: '<?php echo base_url();?>ajax',
type: 'POST',
data: {
action:'getAvailableWorkersByServiceCategory',
serv_cat_slug:categorySlug
},
error: function() {
},
dataType: 'json',
success: function(data) {
},
}).done(function(result){
$('#workerTable').html(result.workerListShow);
if(result.workers && result.workers.length > 0){
setWorker(result.workers[0].worker_id);
}
else{
$('#workerTable').html('<div class="noWorkerMessage">Ingen ledige medarbeidere for denne tjenesten.</div>');
setWorker('');
}
});
}
$(function(){
$("#datepicker").datepicker({
firstDay: 1,
defaultDate: new Date(),
dateFormat: 'yy-mm-dd',
onSelect: function(dateText) {
$('#booking_date').val(this.value);
getAvaliableTimesOfTheDay(this.value);
//console.log("Selected date: " + dateText + "; input's current value: " + this.value);
}
});
});
function getAvaliableTimesOfTheDay(selectedDate){
$('.bookingResultsWrapper').show();
$('#serviceBookingDate').html(selectedDate);
$('#serviceBookingDate').show();
//console.log(selectedDate);
$.ajax({
url: '<?php echo base_url();?>ajax',
type: 'POST',
data: {
action:'getAvailableTimes',
worker_id:$('#worker_id').val(),
servicelength:$('#servicelength').val(),
selectedDate:selectedDate
},
error: function() {
},
//dataType: 'json',
success: function(data) {
},
}).done(function(result){
if(result != 0){
$('.availableTimes').html(result);
}
else{
$('.availableTimes').html('There is not available time on this day!');
}
});
}
function setSelectedTime(time, id){
$('#booking_time').val(time).trigger('change');
$('#serviceBookingTime').html(time);
$('#serviceBookingTime').show();
$('.availableBookingTime').each(function(i, obj) {
$(this).removeClass('selectedTime');
});
//$('#bookingStep4').show();
$('#bookingTime_'+id).addClass('selectedTime');
}
function goToStep(stepNumber){
if(canGotoNextStep(stepNumber)){
$('.stepCircle').each(function(i, obj) {
$(this).removeClass('activeStep');
});
//$('#stepCircle'+stepNumber).addClass('activeStep');
$('.BookingStepContainer').each(function(i, obj) {
$(this).hide();
});
$('#bookingStep'+stepNumber).show();
if(stepNumber == 4){
recaptcha_callback();
$('#stepCircle1').addClass('activeStep');
$('#stepCircle2').addClass('activeStep');
$('#stepCircle3').addClass('activeStep');
}
else if(stepNumber == 3){
if($("#datepicker").val() != ''){
getAvaliableTimesOfTheDay($("#datepicker").val());
}
$('.bookingSummaryContainer').show();
$('#stepCircle1').addClass('activeStep');
$('#stepCircle2').addClass('activeStep');
$('#stepCircle3').addClass('activeStep');
}
else{
if(stepNumber == 1){
$('#stepCircle1').addClass('activeStep');
}
if(stepNumber == 2){
$('#stepCircle1').addClass('activeStep');
$('#stepCircle2').addClass('activeStep');
}
$('.bookingSummaryContainer').hide();
$('.bookingResultsWrapper').hide();
}
$('html').scrollTop(0);
}
else{
alert('Please fill the required fields!');
}
}
function canGotoNextStep(stepNumber){
$RequiredFieldSelected = 0;
if(stepNumber == 2){
$('.service').each(function(i, obj) {
if($(this).is(':checked')){
$RequiredFieldSelected = 1;
}
});
}
else if(stepNumber == 1){
$RequiredFieldSelected = 1;
}
else if(stepNumber == 4){
recaptcha_callback();
$RequiredFieldSelected = 1;
}
else if(stepNumber == 3){
$('.workers').each(function(i, obj) {
if($(this).is(':checked')){
$RequiredFieldSelected = 1;
}
});
}
else if(stepNumber == 4){
recaptcha_callback();
if($('#booking_date').val() != '' && $('#booking_time').val() != ''){
$RequiredFieldSelected = 1;
}
}
return $RequiredFieldSelected;
}
function setWorker(worker_id){
// csak az aktuális worker ID-t állítjuk be a formban
$('#worker_id').val(worker_id);
// ne jelenjen meg worker név/kép a jobb oldali panelen
$('.serviceBookingWorkerContainer').hide().html('');
}
</script>