guest booking self-management feature + layout fixes

- add manage_token column to bookings (DB migration done)
- generate unique token per booking, include manage link in confirmation emails (no/en/hu)
- new routes: manage-booking, manage-booking-process, manage-booking-cancel
- new views: manage-booking.php, manage-booking-cancelled.php
- 24h cutoff enforcement for cancel/modify; emails sent to guest + studio CC
- fix manage-booking step 3 float layout (overflow:auto BFC clearfix)
- fix booking page time slot overflow: bookingResultsWrapper 378px -> 360px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:01:12 +01:00
co-authored by Claude Sonnet 4.6
parent 02169a89f6
commit 2183c8aff2
6 changed files with 981 additions and 65 deletions
+284 -33
View File
@@ -324,6 +324,7 @@ class Pages extends CI_Controller {
$servicelength = gmdate('H:i:s', $totalLengthSeconds);
// 3. Build booking array
$manageToken = bin2hex(random_bytes(16));
$bookingArray = array(
'guest_name' => $_POST['guest_name'],
'guest_email' => $_POST['guest_email'],
@@ -335,6 +336,7 @@ class Pages extends CI_Controller {
'service_ids' => serialize($selectedServiceArray),
'guest_confirmed' => '0',
'guest_confirm_code' => '1234',
'manage_token' => $manageToken,
);
$bookingDate = $bookingArray['booking_date'];
@@ -459,6 +461,7 @@ class Pages extends CI_Controller {
$content .= '</table></td>';
$content .= '<tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Administrer bestilling</a><br />';
$content .= '<br /> Dersom noe skulle dukke opp og du ikke kan komme til avtalen din, vennligst gi oss beskjed minst 24 timer i forveien, slik at vi kan gi timen videre til en annen kunde og våre kollegers tid ikke blir stående ubrukt; avbestillinger innen 24 timer, eller manglende oppmøte, vil bli belastet med full pris for behandlingen; avbestilling kan gjøres via e-post til <a href="mailto:info@studiobeve.no">info@studiobeve.no</a> eller ved å sende oss en melding på Instagram eller Facebook. Takk for forståelsen og samarbeidet!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
@@ -483,6 +486,7 @@ class Pages extends CI_Controller {
$content .= '</table></td>';
$content .= '<tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Manage booking</a><br />';
$content .= '<br /> If something comes up and you cant make it to your booked appointment, please let us know at least 24 hours in advance so we can offer the time slot to another guest and our colleagues time wont remain unused; cancellations within 24 hours or no-shows will be charged the full service fee; you can cancel by emailing <a href="mailto:info@studiobeve.no">info@studiobeve.no</a>, or by sending us a message on Instagram or Facebook. Thank you for your understanding and cooperation!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
@@ -507,6 +511,7 @@ class Pages extends CI_Controller {
$content .= '</table></td>';
$content .= '<tr><td>Összesen fizetendő: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Foglalás kezelése</a><br />';
$content .= '<br /> Kérünk, ha valami közbejön, és mégsem tudsz eljönni a lefoglalt időpontodra, jelezd nekünk legalább 24 órával előre, így más vendégnek is tudunk szabad időpontot biztosítani, és kollégáink ideje sem marad kihasználatlanul; 24 órán belüli lemondás, illetve meg nem jelenés esetén a szolgáltatás teljes díja felszámításra kerül; a lemondást az <a href="mailto:info@studiobeve.no">info@studiobeve.no</a>
e-mail címen, vagy Instagram- és Facebook-üzenetben tudod megtenni. Köszönjük a megértést és az együttműködést!';
$content .= '<br />';
@@ -666,38 +671,284 @@ class Pages extends CI_Controller {
}
}
public function manage_booking($lang, $token){
$this->load->helper('url');
$this->load->model('Service_model');
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
// Determine booking type from services
$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;
}
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
$data['token'] = $token;
$data['booking'] = $booking;
$data['selectedLang'] = $lang;
$data['subpage'] = $subpage;
$data['pageTitle'] = '';
$data['status'] = 'ok';
if($appointmentDT < $now){
$data['status'] = 'expired';
} elseif(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$data['status'] = 'cutoff';
}
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
$data['selectedServiceIds'] = is_array($serviceIds) ? $serviceIds : array();
$data['worker'] = $this->Service_model->getWorkerById($booking->worker_id);
$this->load->view('pages/manage-booking', $data);
}
public function manage_booking_process(){
$this->load->helper('url');
$this->load->model('Service_model');
if(!isset($_POST['token']) || !isset($_POST['sendBooking'])){
header('Location:'.SITEURL);
return;
}
$token = $_POST['token'];
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Modification cutoff has passed.']));
return;
}
// Build selected service array and total length
$selectedServiceArray = array();
$totalLengthSeconds = 0;
foreach($_POST as $key => $value){
if(strpos($key, 'service_') === 0 && $value == '1'){
$serviceId = str_replace('service_', '', $key);
$selectedServiceArray[] = $serviceId;
$service = $this->Service_model->getServiceById($serviceId);
if($service){
$parts = explode(':', $service->service_time);
$totalLengthSeconds += ($parts[0] * 3600) + ($parts[1] * 60);
}
}
}
$servicelength = gmdate('H:i:s', $totalLengthSeconds);
$newBookingDate = $_POST['booking_date'];
$newStartTime = $_POST['booking_start_time'];
$newWorkerId = $_POST['worker_id'];
$lang = $_POST['lang'];
$subpage = $_POST['subpage'];
// Schedule check
$weekday = date('w', strtotime($newBookingDate));
$schedule = $this->Service_model->getWorkerScheduleByDay($newWorkerId, $weekday);
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.']));
return;
}
if($newStartTime < $schedule->start_time || $newStartTime >= $schedule->end_time){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Booking time is outside worker schedule.']));
return;
}
if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker only works every second week.']));
return;
}
// Slot availability (temporarily exclude current booking)
$this->Service_model->updateBooking($booking->booking_id, ['booking_date' => '1970-01-01']);
$available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength);
$this->Service_model->updateBooking($booking->booking_id, ['booking_date' => $booking->booking_date]);
if(!is_array($available) || !in_array($newStartTime, $available)){
$this->output->set_status_header(409)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Conflict. Timeslot is taken or does not fit the service.']));
return;
}
// 3-month limit
$today = new DateTime();
$maxDate = (clone $today)->modify('+3 months');
$selectedDate = new DateTime($newBookingDate);
if($selectedDate > $maxDate){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Bookings can only be made up to 3 months in advance.']));
return;
}
// Calculate finish time
$bookingStart = new DateTime($newStartTime);
$bookingLength = new DateTime($servicelength);
$bookingEnd = clone $bookingStart;
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('H')).'H'));
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M'));
$closingTime = new DateTime('18:00');
if($bookingEnd > $closingTime){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Selected time exceeds business hours.']));
return;
}
$finishTime = $bookingEnd->format('H:i:s');
// Update booking
$this->Service_model->updateBooking($booking->booking_id, array(
'worker_id' => $newWorkerId,
'booking_date' => $newBookingDate,
'booking_start_time' => $newStartTime,
'booking_finish_time' => $finishTime,
'service_ids' => serialize($selectedServiceArray),
));
$worker = $this->Service_model->getWorkerById($newWorkerId);
$serviceArray = array();
foreach($selectedServiceArray as $serviceItem){
$sel = $this->Service_model->getServiceById($serviceItem);
if(is_object($sel)) $serviceArray[] = $sel;
}
$manageLink = SITEURL.$lang.'/manage-booking/'.$token;
$totalServicePrice = 0;
foreach($serviceArray as $s){ $totalServicePrice += $s->service_price; }
switch($lang){
case 'no':
$subject = '[studiobeve] Din bestilling er oppdatert';
$content = 'Kjære gjest,<br />Din bestilling er oppdatert.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Arbeider: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dato: </td><td>'.$newBookingDate.'</td></tr>';
$content .= '<tr><td>Tid: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Tjenester: </td><td><table>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_no.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td><tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Administrer bestilling</a>';
$content .= '<br /><br /> STUDIOBEVE';
break;
case 'hu':
$subject = '[studiobeve] A foglalásod módosítva lett';
$content = 'Kedves Vendégünk,<br />A foglalásod módosítva lett.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Dolgozó: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dátum: </td><td>'.$newBookingDate.'</td></tr>';
$content .= '<tr><td>Időpont: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Szolgáltatás(ok): </td><td><table>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_hu.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td><tr><td>Összesen: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Foglalás kezelése</a>';
$content .= '<br /><br /> STUDIOBEVE';
break;
default:
$subject = '[studiobeve] Your booking has been updated';
$content = 'Dear Guest,<br />Your booking has been updated.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Worker: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Date: </td><td>'.$newBookingDate.'</td></tr>';
$content .= '<tr><td>Time: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Services: </td><td><table>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_en.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td><tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Manage booking</a>';
$content .= '<br /><br /> STUDIOBEVE';
}
$emailArray = array(array(
'addressee_email' => $booking->guest_email,
'addressee_name' => $booking->guest_name,
'subject' => $subject,
'content' => $content,
'attachments' => array()
));
$this->load->model('User_model');
$this->User_model->sendEmail($emailArray, $lang, '');
header('Location:'.SITEURL.$lang.'/booking-finished/'.$subpage);
}
public function manage_booking_cancel(){
$this->load->helper('url');
$this->load->model('Service_model');
$this->load->model('User_model');
if(!isset($_POST['token'])){
header('Location:'.SITEURL);
return;
}
$token = $_POST['token'];
$lang = isset($_POST['lang']) ? $_POST['lang'] : 'en';
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Cancellation cutoff has passed.']));
return;
}
$worker = $this->Service_model->getWorkerById($booking->worker_id);
switch($lang){
case 'no':
$subject = '[studiobeve] Din bestilling er avbestilt';
$content = 'Kjære gjest,<br />Din reservasjon den '.$booking->booking_date.' kl. '.date('H:i', strtotime($booking->booking_start_time)).' med '.$worker->worker_name.' er avbestilt.<br /><br /> STUDIOBEVE';
break;
case 'hu':
$subject = '[studiobeve] A foglalásod törölve lett';
$content = 'Kedves Vendégünk,<br />A '.$booking->booking_date.' '.$booking->booking_start_time.'-os foglalásod '.$worker->worker_name.'-nál törölve lett.<br /><br /> STUDIOBEVE';
break;
default:
$subject = '[studiobeve] Your booking has been cancelled';
$content = 'Dear Guest,<br />Your booking on '.$booking->booking_date.' at '.date('H:i', strtotime($booking->booking_start_time)).' with '.$worker->worker_name.' has been cancelled.<br /><br /> STUDIOBEVE';
}
$emailArray = array(array(
'addressee_email' => $booking->guest_email,
'addressee_name' => $booking->guest_name,
'subject' => $subject,
'content' => $content,
'attachments' => array()
));
$this->User_model->sendEmail($emailArray, $lang, '');
$this->Service_model->deleteBooking($booking->booking_id);
$data['selectedLang'] = $lang;
$data['booking'] = $booking;
$data['worker'] = $worker;
$data['pageTitle'] = '';
$this->load->view('pages/manage-booking-cancelled', $data);
}
}