Compare commits

..
7 Commits
Author SHA1 Message Date
UbuntuandClaude Opus 5 ce7400578a Apply the worker capability filter to the manage-booking flow
The per-worker service allow-list was only enforced on the new-booking form.
On the guest manage-booking page a worker could still be offered for services
they do not perform:

- manage_booking() branched on $firstService, which was never assigned - the
  resolve loop above it used $resolvedService - so the guarded branch was dead
  and the worker list fell through to getActiveWorkers(), filtered by vertical
  only. Assigning it activates both the category filter and the capability
  check, and with it the $isOtherCategory checkbox disabling in the view.
- The page's own getAvailableWorkersByServiceCategorySlug() never sent
  service_ids, so re-picking services asked for a category-only worker list.
  It now mirrors the booking form, including the empty-result message.
- setWorker() clears any time already chosen: changing worker or services left
  a stale selection with the submit button still enabled.

manage_booking_process() did re-check capability server-side, but every
rejection there answered with raw JSON, which this non-AJAX form renders as an
unstyled blob in the guest's browser. All eight rejections, plus the
cancellation cutoff, now go through _booking_error(), which takes an optional
back URL so the guest returns to their own manage page rather than an empty
booking form. Adds the cutoff_passed message in all three languages and makes
the error title and button label reflect which flow failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYGSbK1erKv7VG1pvdPEjG
2026-08-24 17:06:08 +00:00
UbuntuandClaude Opus 5 13e1b1a672 Add Amarildo to the barber page and gate workers by service
New barber Amarildo Champimpi is introduced on the barber page in all three
languages, and workers can now be restricted to a subset of the services in
their category.

Amarildo cannot perform beard colouring, any waxing, or the all-in package, and
he is a barber only - but category 1 "Kozmetika" holds 19 barber AND 27 beauty
services, so the category-derived vertical wrongly made him beauty-capable.
Because the worker picker is only ever fetched AFTER services are ticked, one
per-service capability filter solves both problems: excluding him from every
beauty service removes him from that vertical entirely.

worker_services(worker_id, service_id) is an allow-list where an EMPTY set means
UNRESTRICTED. That default is deliberate: a missing migration degrades to the
previous behaviour instead of hiding every worker from the booking flow, and
existing workers keep working untouched. Ticking every box in the admin grid
stores nothing at all, so an unrestricted worker also picks up services added
later; unticking even one makes the worker restricted, and new services must
then be granted explicitly.

Enforcement is in three places. The picker offers only workers who can perform
EVERY selected service, and both booking paths re-check server-side, since the
picker is only a UI affordance - a crafted POST now gets service_not_offered/403
rather than a booking the worker cannot honour.

Fixed alongside, all found while building the above:

- getWorkersByCategorySlug() never filtered is_active, so marking a worker
  inactive had NO effect on the public booking flow. Both of its callers are
  guest-facing. The sibling fallback getActiveWorkers() had always filtered it.
- Worker profile picture uploads failed SILENTLY above PHP's upload_max_filesize.
  Both upload blocks gated on tmp_name alone, which cannot distinguish a rejected
  upload from "no file chosen" - PHP empties tmp_name in both cases - so the
  worker was saved with an empty worker_profile_img and no error shown. The
  upload error code is now read and reported, and a separate guard catches
  post_max_size overflow, where $_POST and $_FILES both arrive empty and the form
  silently did nothing at all.
- createWorker() omitted is_active from its INSERT, so the column default (1)
  always won and a worker created as inactive silently came back active.

Migration - the table MUST be created before this code is deployed, because the
picker query subselects it whenever service ids are passed:

    CREATE TABLE worker_services (
      worker_id  INT NOT NULL,
      service_id INT NOT NULL,
      PRIMARY KEY (worker_id, service_id),
      KEY idx_worker_services_worker (worker_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Already applied on test, dev and prod. Server-side, upload_max_filesize/
post_max_size were raised to 8M/12M on all three environments (php.ini on test,
.user.ini on the shared-host dev and prod docroots) - not carried by this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYGSbK1erKv7VG1pvdPEjG
2026-08-24 12:07:07 +00:00
UbuntuandClaude Opus 5 4c823f92ca Fix lunch-break guard being skipped on same-day bookings
getAvailableTimes() overloaded $dayStartTime: it starts as the worker's
real shift start, but for same-day requests it is overwritten with the
earliest bookable slot (next 15-min block + 1 hour). The lunch-break
setup further down still read it as the shift start, so $shiftSeconds
measured the *remaining* day instead of the whole shift. Once that fell
under 6 hours, $lunchRequired went false and the guard was skipped
entirely -- every free slot was offered, including ones that leave no
room for a lunch break.

Observed on prod on 2026-08-21: at 12:17 the cutoff pushed the start to
13:30, so Kateryna's 10:00-18:00 shift measured 4.5h and a 13:30-15:30
booking was accepted even though it consumed the last possible lunch
slot. booking_process() re-validates through the same function, so the
server-side check agreed.

Capture the real shift start in $shiftStartTime before the cutoff runs,
and use it for both $shiftSeconds and the computeLunchBreak() call. The
latter also fixes a second symptom of the same overload: the lunch
window was being clamped to the cutoff rather than to the actual shift.

Verified end-to-end against pages/ajax on test: with the bug a slot that
destroys the last lunch break is offered, with the fix it is withheld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZv7QGLyRYwdD8NyimYbQQ
2026-08-21 11:14:18 +00:00
UbuntuandClaude Opus 5 1a364f21b3 Replace Vincent's gallery photos and align the strip with the bio
New treatment-room photos for the four-image strip in the massage
About-us block, cropped square at 800x800 to match the existing set
(the block is flex, and a non-square crop leaves the strip visibly
short against the bio) and stripped of EXIF.

Each is the full 4284px frame width, with the vertical offset picked
to hold both Vincent's face and the hands-on-client action. In pics 1
and 4 the subject is taller than the frame is wide, so the crop
sacrifices the top of his hair rather than the treatment itself.

Also corrects the strip's vertical alignment. The two columns were
already align-items: flex-start and did start level; the offset came
from the text, which is 13pt/1.75em and therefore carries 6.5px of
half-leading above its first line, while an inline image starts flush
at its box top. padding-top: 0.375em is exactly that half-leading. It
is zeroed at the 480px breakpoint, where the container turns
display:block and the strip no longer sits beside the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wwCBWRnEZuorhv34FVUkY
2026-08-16 11:37:06 +00:00
UbuntuandClaude Opus 5 06088aee8b Replace Vincent's profile photo with the new studio shot
Both profile images are regenerated from the same new source photo,
cropped square and stripped of EXIF (the original iPhone file carried
GPS coordinates).

The two crops differ on purpose, because the display sizes do:
- massage/vincent/vincent.jpg (400px, shown as a 150px circle) gets a
  wide head-and-shoulders crop showing the studio behind him
- worker_Vincent_Pusch.jpg (300px, shown as a 75px circle in the
  booking worker picker) stays tight, so the face is still
  recognisable at that size

Filenames are unchanged, so workers.worker_profile_img needs no
migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wwCBWRnEZuorhv34FVUkY
2026-08-16 10:27:59 +00:00
UbuntuandClaude Opus 5 baa597a7c5 Guard against null permission_ids in getModulesByPermissionSlug()
modules.permission_ids is NULL for module 8 ("Szerviz kategoriak") on every
environment, and PHP 8.1 deprecates passing null to explode(). The notice was
only visible on prod because its index.php still has the stock
error_reporting(-1), whereas dev was previously changed to
E_ALL & ~E_DEPRECATED & ~E_WARNING & ~E_NOTICE.

- Skip rows whose permission_ids is null or empty. Behaviour-preserving:
  explode(',', null) returned array(''), which matched no permission id, so
  such modules were already excluded. Verified by simulating old vs new
  against the real module rows across six permission-id values including 0,
  '1' and 'admin' - identical results.
- Return early when the slug matches no permission, rather than dereferencing
  null on the next line.

Does not address the wider issue that prod runs ENVIRONMENT='development'
with display_errors=1, so any notice is rendered to real visitors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:49:07 +00:00
UbuntuandClaude Opus 5 35115404be Validate the guest e-mail before the booking is saved
An address like "asdf" used to be accepted: the field was type="text" with
only `required`, and there was no server-side check. createBooking() runs
long before sendEmail(), so the failure was silent rather than loud -
reproduced end to end on test:

  - the booking row WAS created, with a manage_token
  - PHPMailer's addAddress() threw, so nothing was ever sent
  - the Location header was already queued, so the guest was redirected to
    the normal "booking finished" page and saw success
  - Evelin is a CC on that same message, so the salon was not told either
  - the guest had no manage link, so they could not cancel

Fixes
- booking_process() rejects an empty or malformed address BEFORE any write,
  returning invalid_email / HTTP 400. Message added in all three languages,
  worded to say why it matters (the confirmation and the manage link go
  there). filter_var is equal-or-stricter than PHPMailer's own validator -
  checked against it on ten cases - so anything accepted here cannot throw
  later.
- The three public booking forms use type="email", so most typos never
  reach the server.
- Removed three debug echoes from User_model::sendEmail() that leaked $lang
  and Hungarian strings ("Üzenet elküldve", "Üzenetküldési hiba. Mailer
  Error: ...") into the guest-facing response.

Scope
- Public flow only. 508 existing bookings have an empty guest_email because
  admin-created block bookings legitimately have none; those go through
  Admin::booking_process(), which is untouched, and its form stays
  type="text".
- Not covered: a valid address whose SMTP delivery fails still leaves the
  booking created and the guest seeing success, logged only via
  log_message(). Different failure mode, needs a separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:55:33 +00:00
26 changed files with 797 additions and 55 deletions
+103 -3
View File
@@ -547,6 +547,13 @@ class Admin extends CI_Controller {
$data['selectedItem'] = $this->Service_model->getWorkerById($worker_id); $data['selectedItem'] = $this->Service_model->getWorkerById($worker_id);
// Verticals are derived from the worker's service category, not stored. // Verticals are derived from the worker's service category, not stored.
$data['workerVerticals'] = $this->Service_model->getVerticalsForWorker($worker_id); $data['workerVerticals'] = $this->Service_model->getVerticalsForWorker($worker_id);
// Capability grid: which of the category's services this worker performs.
// An empty stored set means unrestricted, which the view renders as
// everything ticked - see the note in the form.
$data['categoryServices'] = $this->Service_model->getServicesByCategoryId($data['selectedItem']->service_category_id);
$data['workerServiceIds'] = $this->Service_model->getWorkerServiceIds($worker_id);
$this->load->view('admin/update-worker', $data); $this->load->view('admin/update-worker', $data);
} }
else{ else{
@@ -554,6 +561,48 @@ class Admin extends CI_Controller {
} }
} }
/**
* Translate the PHP upload error code for the worker profile picture into
* an admin-facing message.
*
* The upload blocks below used to gate on tmp_name alone, which made a
* REJECTED upload indistinguishable from "no file chosen": PHP empties
* tmp_name in both cases, so an oversized photo was skipped in silence and
* the worker was saved with an empty worker_profile_img. Reading the error
* code is the only way to tell the two apart.
*
* @return string '' when there is nothing to report (no file chosen, or a
* file that arrived intact), otherwise the message to show.
*/
private function _workerImgUploadError(){
// No file part at all - nothing was submitted, nothing to report.
if(!isset($_FILES['worker_img']) || !isset($_FILES['worker_img']['error'])){
return '';
}
switch($_FILES['worker_img']['error']){
case UPLOAD_ERR_OK:
case UPLOAD_ERR_NO_FILE:
// Intact, or the admin simply left the field empty.
return '';
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return 'A kép túl nagy! A megengedett legnagyobb méret: '.ini_get('upload_max_filesize').'.';
case UPLOAD_ERR_PARTIAL:
return 'A kép csak részben töltődött fel. Kérlek próbáld újra!';
case UPLOAD_ERR_NO_TMP_DIR:
case UPLOAD_ERR_CANT_WRITE:
case UPLOAD_ERR_EXTENSION:
return 'A kép feltöltése szerverhiba miatt nem sikerült. Szólj az üzemeltetőnek!';
default:
return 'Ismeretlen hiba a kép feltöltése közben.';
}
}
public function worker_process(){ public function worker_process(){
$this->load->helper('url'); $this->load->helper('url');
$this->load->model('User_model'); $this->load->model('User_model');
@@ -565,7 +614,16 @@ class Admin extends CI_Controller {
$data['pageTitle'] = ''; $data['pageTitle'] = '';
$data['message'] = ''; $data['message'] = '';
$data['message_class'] = ''; $data['message_class'] = '';
// A POST body larger than post_max_size is discarded WHOLESALE by PHP:
// $_POST and $_FILES both arrive empty, so none of the branches below
// fire and the form appears to do nothing at all. CONTENT_LENGTH still
// reports the real size, which is the only surviving evidence.
if($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && !empty($_SERVER['CONTENT_LENGTH'])){
$data['message'] = 'A beküldött adat túl nagy volt, ezért a mentés nem történt meg. A kép legfeljebb '.ini_get('upload_max_filesize').' lehet.';
$data['message_class'] = 'errorMessage';
}
if(isset($_GET['delete-worker'])){ if(isset($_GET['delete-worker'])){
$selected_worker = $this->Service_model->getWorkerById($_GET['delete-worker']); $selected_worker = $this->Service_model->getWorkerById($_GET['delete-worker']);
$workerArray = array( $workerArray = array(
@@ -581,7 +639,12 @@ class Admin extends CI_Controller {
$data['worker_img'] = ''; $data['worker_img'] = '';
if($_FILES["worker_img"]["tmp_name"] != ''){ $workerImgError = $this->_workerImgUploadError();
if($workerImgError !== ''){
$data['message'] = $workerImgError;
$data['message_class'] = 'errorMessage';
} elseif($_FILES["worker_img"]["tmp_name"] != ''){
$target_dir = getcwd()."/assets/img/"; $target_dir = getcwd()."/assets/img/";
$target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']); $target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']);
$imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION)); $imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION));
@@ -632,7 +695,44 @@ class Admin extends CI_Controller {
$this->Service_model->updateWorker($_POST['worker_id'], $workerArray); $this->Service_model->updateWorker($_POST['worker_id'], $workerArray);
$this->Log_model->addLog('workers', $data['currentUser']->username.' módosította a(z) '.$_POST['worker_name'].' nevű dolgozót ('.implode(',',$workerArray).')', $data['currentUser']->username); $this->Log_model->addLog('workers', $data['currentUser']->username.' módosította a(z) '.$_POST['worker_name'].' nevű dolgozót ('.implode(',',$workerArray).')', $data['currentUser']->username);
if($_FILES["worker_img"]["tmp_name"] != ''){ // Capability set. The grid posts the ticked service ids; anything not
// belonging to the worker's (possibly just-changed) category is dropped
// so a category switch cannot leave orphaned rows behind.
$postedServiceIds = isset($_POST['worker_services']) && is_array($_POST['worker_services'])
? $_POST['worker_services']
: array();
$categoryServices = $this->Service_model->getServicesByCategoryId($_POST['service_category_id']);
$categoryServiceIds = array();
if(is_array($categoryServices)){
foreach($categoryServices as $svc){
$categoryServiceIds[] = (int)$svc->service_id;
}
}
$allowedServiceIds = array();
foreach($postedServiceIds as $postedId){
if(in_array((int)$postedId, $categoryServiceIds, TRUE)){
$allowedServiceIds[] = (int)$postedId;
}
}
// Every service ticked means "no restriction": store nothing, so the
// worker keeps performing services added in the future too. Storing the
// full set instead would silently freeze them out of every new service.
if(count($allowedServiceIds) === count($categoryServiceIds)){
$allowedServiceIds = array();
}
$this->Service_model->setWorkerServiceIds($_POST['worker_id'], $allowedServiceIds);
$this->Log_model->addLog('workers', $data['currentUser']->username.' beállította a(z) '.$_POST['worker_name'].' nevű dolgozó végezhető szolgáltatásait ('.(empty($allowedServiceIds) ? 'nincs korlátozás' : implode(',', $allowedServiceIds)).')', $data['currentUser']->username);
$workerImgError = $this->_workerImgUploadError();
if($workerImgError !== ''){
$data['message'] = $workerImgError;
$data['message_class'] = 'errorMessage';
} elseif($_FILES["worker_img"]["tmp_name"] != ''){
$target_dir = getcwd()."/assets/img/"; $target_dir = getcwd()."/assets/img/";
$target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']); $target_file = $target_dir . 'worker_'.str_replace(' ','_', $_POST['worker_name']);
$imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION)); $imageFileType = strtolower(pathinfo(basename($_FILES["worker_img"]["name"]),PATHINFO_EXTENSION));
+71 -24
View File
@@ -137,7 +137,15 @@ class Pages extends CI_Controller {
$data['pageTitle'] = ''; $data['pageTitle'] = '';
if(isset($_POST['action']) && $_POST['action'] == 'getAvailableWorkersByServiceCategory' && isset($_POST['serv_cat_slug'])){ if(isset($_POST['action']) && $_POST['action'] == 'getAvailableWorkersByServiceCategory' && isset($_POST['serv_cat_slug'])){
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug']); // The picker must only offer workers who can perform EVERY service the
// guest has ticked. service_ids is supplied by the booking form; when it
// is absent (older cached JS) the call degrades to the category-only
// behaviour rather than returning nobody.
$requestedServiceIds = isset($_POST['service_ids']) && is_array($_POST['service_ids'])
? $_POST['service_ids']
: array();
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug'], $requestedServiceIds);
$returnedWorkers = ''; $returnedWorkers = '';
$workerIndex = 0; $workerIndex = 0;
$workerListShow = ''; $workerListShow = '';
@@ -307,7 +315,7 @@ class Pages extends CI_Controller {
* form is a normal full-page POST, so whatever this outputs is what the * form is a normal full-page POST, so whatever this outputs is what the
* customer actually sees in their browser. * customer actually sees in their browser.
*/ */
private function _booking_error($errorCode, $statusCode = 409){ private function _booking_error($errorCode, $statusCode = 409, $backUrl = ''){
$this->load->helper('url'); $this->load->helper('url');
$lang = vertical_lang(isset($_POST['lang']) ? $_POST['lang'] : ''); $lang = vertical_lang(isset($_POST['lang']) ? $_POST['lang'] : '');
@@ -324,7 +332,10 @@ class Pages extends CI_Controller {
'selectedLang' => $lang, 'selectedLang' => $lang,
'subpage' => $vertical['slug'], 'subpage' => $vertical['slug'],
'vertical' => $vertical, 'vertical' => $vertical,
'bookingErrorCode' => $errorCode 'bookingErrorCode' => $errorCode,
// Manage-booking errors must return the guest to their own manage page,
// not to a fresh booking form. Empty = the new-booking default.
'bookingBackUrl' => $backUrl
); );
$this->output->set_status_header($statusCode); $this->output->set_status_header($statusCode);
@@ -403,7 +414,24 @@ class Pages extends CI_Controller {
'guest_confirm_code' => '1234', 'guest_confirm_code' => '1234',
'manage_token' => $manageToken, 'manage_token' => $manageToken,
); );
// Reject an unusable e-mail BEFORE anything is written. The booking is
// saved well before sendEmail() runs, so without this the row is created,
// PHPMailer's addAddress() throws, and the guest is redirected to the
// success page having received nothing - no confirmation and no manage
// link. Evelin is a CC on that same message, so the salon is not told
// either. filter_var is equal-or-stricter than PHPMailer's own check, so
// anything accepted here will not throw later.
//
// Public flow only: admin-created block bookings legitimately carry an
// empty guest_email and go through Admin::booking_process().
$guestEmailInput = isset($_POST['guest_email']) ? trim($_POST['guest_email']) : '';
if ($guestEmailInput === '' || !filter_var($guestEmailInput, FILTER_VALIDATE_EMAIL)) {
$this->_booking_error('invalid_email', 400);
return;
}
$bookingArray['guest_email'] = $guestEmailInput;
$bookingDate = $bookingArray['booking_date']; $bookingDate = $bookingArray['booking_date'];
$bookingTime = $bookingArray['booking_start_time']; $bookingTime = $bookingArray['booking_start_time'];
@@ -428,6 +456,15 @@ class Pages extends CI_Controller {
} }
} }
// Per-worker capability. The category check above only proves the
// services belong to the worker's category - not that this particular
// worker performs them. The picker already hides incapable workers, but
// that is a UI affordance, so re-check before the booking is written.
if (!$this->Service_model->workerCanPerformServices($bookingArray['worker_id'], $selectedServiceArray)) {
$this->_booking_error('service_not_offered', 403);
return;
}
// Single-service verticals: the client disables the other checkboxes, // Single-service verticals: the client disables the other checkboxes,
// but that is a UI affordance only. The vertical is derived from the // but that is a UI affordance only. The vertical is derived from the
// services themselves rather than $_POST['subpage'], which is // services themselves rather than $_POST['subpage'], which is
@@ -820,10 +857,14 @@ class Pages extends CI_Controller {
// wrong vertical's branding. // wrong vertical's branding.
$serviceIds = unserialize($booking->service_ids); $serviceIds = unserialize($booking->service_ids);
$subpage = 'barber'; $subpage = 'barber';
$firstService = NULL;
if(is_array($serviceIds)){ if(is_array($serviceIds)){
foreach($serviceIds as $serviceIdItem){ foreach($serviceIds as $serviceIdItem){
$resolvedService = $this->Service_model->getServiceById($serviceIdItem); $resolvedService = $this->Service_model->getServiceById($serviceIdItem);
if($resolvedService && $resolvedService->service_type !== ''){ if($resolvedService && $resolvedService->service_type !== ''){
// Kept for the worker filter below: the category (and therefore the
// capability check) is derived from this same resolved service.
$firstService = $resolvedService;
$subpage = $resolvedService->service_type; $subpage = $resolvedService->service_type;
break; break;
} }
@@ -859,14 +900,14 @@ class Pages extends CI_Controller {
// Filter workers to those qualified for the booking's service category, // Filter workers to those qualified for the booking's service category,
// so the guest can't switch to a worker who doesn't perform these services. // so the guest can't switch to a worker who doesn't perform these services.
$categorySlug = ''; $categorySlug = '';
if(isset($firstService) && $firstService){ if($firstService){
$category = $this->Service_model->getServiceCategoryById($firstService->service_category_id); $category = $this->Service_model->getServiceCategoryById($firstService->service_category_id);
if($category){ if($category){
$categorySlug = $category->serv_cat_slug; $categorySlug = $category->serv_cat_slug;
} }
} }
if($categorySlug !== ''){ if($categorySlug !== ''){
$data['workers'] = $this->Service_model->getWorkersByCategorySlug($categorySlug); $data['workers'] = $this->Service_model->getWorkersByCategorySlug($categorySlug, is_array($serviceIds) ? $serviceIds : array());
} else { } else {
$data['workers'] = $this->Service_model->getActiveWorkers($subpage); $data['workers'] = $this->Service_model->getActiveWorkers($subpage);
} }
@@ -894,11 +935,17 @@ class Pages extends CI_Controller {
return; return;
} }
// This form is a normal POST, not AJAX, so every rejection below is rendered
// as the shared booking error page. Returning raw JSON showed the guest an
// unstyled {"error":...} blob after they had filled the whole form in.
// Use the stored token, not the posted one: it is echoed straight into the
// error page's back link.
$manageBackUrl = SITEURL.vertical_lang(isset($_POST['lang']) ? $_POST['lang'] : '').'/manage-booking/'.$booking->manage_token;
$now = new DateTime('now', new DateTimeZone('Europe/Oslo')); $now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo')); $appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){ if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('cutoff_passed', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Modification cutoff has passed.']));
return; return;
} }
@@ -929,27 +976,31 @@ class Pages extends CI_Controller {
if(!empty($selectedServiceArray) && $submittedWorker){ if(!empty($selectedServiceArray) && $submittedWorker){
$firstSelectedService = $this->Service_model->getServiceById($selectedServiceArray[0]); $firstSelectedService = $this->Service_model->getServiceById($selectedServiceArray[0]);
if($firstSelectedService && $firstSelectedService->service_category_id != $submittedWorker->service_category_id){ if($firstSelectedService && $firstSelectedService->service_category_id != $submittedWorker->service_category_id){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('category_mismatch', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Selected worker does not perform the chosen services.']));
return; return;
} }
} }
// Per-worker capability, same reasoning as booking_process(): the
// category check proves only that the services belong to the worker's
// category, not that this worker performs them.
if(!$this->Service_model->workerCanPerformServices($newWorkerId, $selectedServiceArray)){
$this->_booking_error('service_not_offered', 403, $manageBackUrl);
return;
}
// Schedule check // Schedule check
$schedule = $this->Service_model->getWorkerScheduleForDate($newWorkerId, $newBookingDate); $schedule = $this->Service_model->getWorkerScheduleForDate($newWorkerId, $newBookingDate);
if(!$schedule){ if(!$schedule){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('worker_unavailable', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Worker is not available on this day.']));
return; return;
} }
if($newStartTime < $schedule->start_time || $newStartTime >= $schedule->end_time){ if($newStartTime < $schedule->start_time || $newStartTime >= $schedule->end_time){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('outside_schedule', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Booking time is outside worker schedule.']));
return; return;
} }
if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){ if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('alternate_week', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Worker only works every second week.']));
return; return;
} }
@@ -957,8 +1008,7 @@ class Pages extends CI_Controller {
$available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength, $booking->booking_id); $available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength, $booking->booking_id);
if(!is_array($available) || !in_array($newStartTime, $available)){ if(!is_array($available) || !in_array($newStartTime, $available)){
$this->output->set_status_header(409)->set_content_type('application/json') $this->_booking_error('slot_taken', 409, $manageBackUrl);
->set_output(json_encode(['error' => 'Conflict. Timeslot is taken or does not fit the service.']));
return; return;
} }
@@ -967,8 +1017,7 @@ class Pages extends CI_Controller {
$maxDate = (clone $today)->modify('+3 months'); $maxDate = (clone $today)->modify('+3 months');
$selectedDate = new DateTime($newBookingDate); $selectedDate = new DateTime($newBookingDate);
if($selectedDate > $maxDate){ if($selectedDate > $maxDate){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('too_far', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Bookings can only be made up to 3 months in advance.']));
return; return;
} }
@@ -980,8 +1029,7 @@ class Pages extends CI_Controller {
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M')); $bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M'));
$closingTime = new DateTime('18:00'); $closingTime = new DateTime('18:00');
if($bookingEnd > $closingTime){ if($bookingEnd > $closingTime){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('after_hours', 403, $manageBackUrl);
->set_output(json_encode(['error' => 'Selected time exceeds business hours.']));
return; return;
} }
$finishTime = $bookingEnd->format('H:i:s'); $finishTime = $bookingEnd->format('H:i:s');
@@ -1143,8 +1191,7 @@ class Pages extends CI_Controller {
$now = new DateTime('now', new DateTimeZone('Europe/Oslo')); $now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo')); $appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){ if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json') $this->_booking_error('cutoff_passed', 403, SITEURL.vertical_lang($lang).'/manage-booking/'.$booking->manage_token);
->set_output(json_encode(['error' => 'Cancellation cutoff has passed.']));
return; return;
} }
+15
View File
@@ -164,10 +164,25 @@ public function getModulesByPermissionSlug($permissionSlug){
$this->load->model('Module_model'); $this->load->model('Module_model');
$selectedPermission = $this->Module_model->getPermissionBySlug($permissionSlug); $selectedPermission = $this->Module_model->getPermissionBySlug($permissionSlug);
// An unknown slug yields no permission at all; without this the line below
// dereferences null. No permission means no modules.
if(!$selectedPermission){
return array();
}
$query = $this->db->query('SELECT * FROM modules;'); $query = $this->db->query('SELECT * FROM modules;');
$results = $query->result(); $results = $query->result();
$resultArray = array(); $resultArray = array();
foreach($results as $resultItem){ foreach($results as $resultItem){
// modules.permission_ids is NULL for at least one row (module 8,
// 'Szerviz kategoriak') on every environment. Passing null to explode()
// is deprecated in PHP 8.1 and emits a notice. A module with no
// permissions is visible to nobody, so skipping it keeps the previous
// behaviour - explode(',', null) returned array('') which matched nothing.
if($resultItem->permission_ids === null || $resultItem->permission_ids === ''){
continue;
}
$permissionIdsArray = explode(',', $resultItem->permission_ids); $permissionIdsArray = explode(',', $resultItem->permission_ids);
if(in_array($selectedPermission->permission_id, $permissionIdsArray)){ if(in_array($selectedPermission->permission_id, $permissionIdsArray)){
$resultArray[] = $resultItem; $resultArray[] = $resultItem;
+190 -7
View File
@@ -127,20 +127,28 @@ class Service_model extends CI_Model {
$this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']); $this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
// is_active was previously omitted from this INSERT, so the column
// default (1) always won and a worker created as inactive silently
// came back active. The admin's choice is honoured here; callers that
// do not supply it still get the old default.
$is_active = isset($workerArray['is_active']) ? (int)$workerArray['is_active'] : 1;
$query = $this->db->query('INSERT INTO workers ( $query = $this->db->query('INSERT INTO workers (
worker_name, worker_name,
worker_profile_img, worker_profile_img,
worker_info, worker_info,
is_beauty, is_beauty,
is_barber, is_barber,
service_category_id service_category_id,
) VALUES(?, ?, ?, ?, ?, ?)', array( is_active
) VALUES(?, ?, ?, ?, ?, ?, ?)', array(
$workerArray['worker_name'], $workerArray['worker_name'],
$workerArray['worker_profile_img'], $workerArray['worker_profile_img'],
$workerArray['worker_info'], $workerArray['worker_info'],
$workerArray['is_beauty'], $workerArray['is_beauty'],
$workerArray['is_barber'], $workerArray['is_barber'],
$workerArray['service_category_id'] $workerArray['service_category_id'],
$is_active
)); ));
} }
@@ -243,6 +251,25 @@ class Service_model extends CI_Model {
} }
} }
/**
* Live services belonging to one category, ordered for display as the
* admin capability grid (sub-heading, then name).
*
* @param int $service_category_id
* @return array
*/
public function getServicesByCategoryId($service_category_id){
$this->load->database();
$query = $this->db->query(
'SELECT * FROM services
WHERE service_category_id = ? AND is_deleted != "1"
ORDER BY service_type ASC, service_category_no ASC, service_id ASC',
array($service_category_id)
);
return $query->result();
}
public function getAllServiceCategories(){ public function getAllServiceCategories(){
$this->load->database(); $this->load->database();
$query = $this->db->query('SELECT * FROM service_categories WHERE is_deleted != "1" ORDER BY service_category_id;'); $query = $this->db->query('SELECT * FROM service_categories WHERE is_deleted != "1" ORDER BY service_category_id;');
@@ -300,9 +327,161 @@ class Service_model extends CI_Model {
return $query->result(); return $query->result();
} }
public function getWorkersByCategorySlug($serv_cat_slug){ /**
* The service ids one worker is allowed to perform.
*
* An EMPTY result means "unrestricted" - the worker performs every live
* service in their category, which is how every worker behaved before
* worker_services existed. Restrictions are therefore opt-in per worker,
* and a missing migration degrades to the old behaviour instead of
* hiding everybody from the booking flow.
*
* @param int $worker_id
* @return array list of service_id ints (empty = unrestricted)
*/
public function getWorkerServiceIds($worker_id){
$this->load->database(); $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" AND serv_cat_slug = ? ORDER BY worker_name ASC', array($serv_cat_slug)); $query = $this->db->query(
'SELECT service_id FROM worker_services WHERE worker_id = ? ORDER BY service_id ASC',
array($worker_id)
);
$ids = array();
foreach($query->result() as $row){
$ids[] = (int)$row->service_id;
}
return $ids;
}
/**
* Replace a worker's allowed-service set.
*
* Passing an empty array clears every row, which restores the worker to
* the unrestricted default rather than making them unable to do anything.
*
* @param int $worker_id
* @param array $service_ids
* @return void
*/
public function setWorkerServiceIds($worker_id, $service_ids){
$this->load->database();
$this->db->query('DELETE FROM worker_services WHERE worker_id = ?', array($worker_id));
if(!is_array($service_ids) || empty($service_ids)){
return;
}
// De-duplicate: the PK would reject repeats and abort the whole save.
$clean = array();
foreach($service_ids as $service_id){
$service_id = (int)$service_id;
if($service_id > 0){
$clean[$service_id] = TRUE;
}
}
foreach(array_keys($clean) as $service_id){
$this->db->query(
'INSERT INTO worker_services (worker_id, service_id) VALUES (?, ?)',
array($worker_id, $service_id)
);
}
}
/**
* Can this worker perform EVERY one of these services?
*
* Used as the server-side guard behind the worker picker: the picker only
* offers capable workers, but that is a UI affordance and a crafted POST
* must not get past it.
*
* @param int $worker_id
* @param array $service_ids
* @return bool
*/
public function workerCanPerformServices($worker_id, $service_ids){
if(!is_array($service_ids) || empty($service_ids)){
return TRUE;
}
$allowed = $this->getWorkerServiceIds($worker_id);
// No rows at all = unrestricted worker.
if(empty($allowed)){
return TRUE;
}
foreach($service_ids as $service_id){
if(!in_array((int)$service_id, $allowed, TRUE)){
return FALSE;
}
}
return TRUE;
}
/**
* Bookable workers for one service category, optionally narrowed to those
* able to perform a specific set of services.
*
* Both callers are guest-facing (the booking worker picker and the
* manage-booking worker list), so an INACTIVE worker must not appear:
* this used to filter on is_deleted alone, which meant switching a
* worker to inactive in the admin had no effect on the public flow.
* getActiveWorkers() - the fallback used by the very same caller when
* no category is known - has always filtered on is_active; the two
* paths now agree.
*
* @param string $serv_cat_slug
* @param array $service_ids selected services; empty = no capability filter
* @return array
*/
public function getWorkersByCategorySlug($serv_cat_slug, $service_ids = array()){
$this->load->database();
$sql = 'SELECT w.* FROM workers w
JOIN service_categories sc ON w.service_category_id = sc.service_category_id
WHERE w.is_deleted != "1" AND w.is_active = "1" AND sc.serv_cat_slug = ?';
$params = array($serv_cat_slug);
// Capability filter. A worker qualifies when they are unrestricted (no
// worker_services rows at all) OR their allowed set covers EVERY
// selected service - counting matched rows against the number asked
// for, so a worker who can do 2 of the 3 chosen services is excluded.
$clean = array();
if(is_array($service_ids)){
foreach($service_ids as $service_id){
$service_id = (int)$service_id;
if($service_id > 0){
$clean[$service_id] = TRUE;
}
}
}
$clean = array_keys($clean);
if(!empty($clean)){
$placeholders = implode(',', array_fill(0, count($clean), '?'));
$sql .= ' AND (
NOT EXISTS (SELECT 1 FROM worker_services wsx WHERE wsx.worker_id = w.worker_id)
OR (
SELECT COUNT(*) FROM worker_services ws
WHERE ws.worker_id = w.worker_id
AND ws.service_id IN ('.$placeholders.')
) = ?
)';
foreach($clean as $service_id){
$params[] = $service_id;
}
$params[] = count($clean);
}
$sql .= ' ORDER BY w.worker_name ASC';
$query = $this->db->query($sql, $params);
return $query->result(); return $query->result();
} }
@@ -622,6 +801,10 @@ class Service_model extends CI_Model {
$finishTime = new DateTime($selectedDate . ' ' . $schedule->end_time, new DateTimeZone('Europe/Oslo')); $finishTime = new DateTime($selectedDate . ' ' . $schedule->end_time, new DateTimeZone('Europe/Oslo'));
} }
// A műszak valódi kezdete - $dayStartTime-ot a mai napra alább felülírjuk a legkorábban
// foglalható időponttal, az ebédszünet számítása viszont a tényleges műszakkal dolgozik.
$shiftStartTime = clone $dayStartTime;
// --- ÚJ: ma foglalva csak (következő 15 perces blokk + 1 óra) UTÁN legyen időpont --- // --- ÚJ: ma foglalva csak (következő 15 perces blokk + 1 óra) UTÁN legyen időpont ---
if ($selectedDate == date('Y-m-d')) { if ($selectedDate == date('Y-m-d')) {
// használd a helyi (Oslo) időt, ne az esetleges UTC defaultot // használd a helyi (Oslo) időt, ne az esetleges UTC defaultot
@@ -648,7 +831,7 @@ class Service_model extends CI_Model {
// --- Lunch break setup --- // --- Lunch break setup ---
// Pre-fetch all bookings for the day so we can do hypothetical checks per slot // Pre-fetch all bookings for the day so we can do hypothetical checks per slot
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = ? LIMIT 1", array($worker_id))->row(); $workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = ? LIMIT 1", array($worker_id))->row();
$shiftSeconds = $finishTime->getTimestamp() - $dayStartTime->getTimestamp(); $shiftSeconds = $finishTime->getTimestamp() - $shiftStartTime->getTimestamp();
$lunchRequired = $workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600; $lunchRequired = $workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600;
$allBookings = []; $allBookings = [];
@@ -705,7 +888,7 @@ class Service_model extends CI_Model {
]]); ]]);
$canStillBreak = $this->computeLunchBreak( $canStillBreak = $this->computeLunchBreak(
$workerRow->lunch_window_start, $workerRow->lunch_window_end, $workerRow->lunch_window_start, $workerRow->lunch_window_end,
$selectedDate, $hypo, $finishTime, $dayStartTime, $selectedDate, $hypo, $finishTime, $shiftStartTime,
$workerRow->lunch_preferred_time $workerRow->lunch_preferred_time
) !== null; ) !== null;
if (!$canStillBreak) continue; // booking this slot would eliminate the only break opportunity if (!$canStillBreak) continue; // booking this slot would eliminate the only break opportunity
+4 -3
View File
@@ -132,7 +132,6 @@ class User_model extends CI_Model {
public function sendEmail($addressList, $lang, $subpage){ public function sendEmail($addressList, $lang, $subpage){
// Import PHPMailer classes into the global namespace // Import PHPMailer classes into the global namespace
// These must be at the top of your script, not inside a function // These must be at the top of your script, not inside a function
echo $lang;
if(!empty($addressList)){ if(!empty($addressList)){
foreach($addressList as $addressListItem){ foreach($addressList as $addressListItem){
// Instantiation and passing `true` enables exceptions // Instantiation and passing `true` enables exceptions
@@ -179,7 +178,6 @@ class User_model extends CI_Model {
//$mail->AltBody = strip_tags($answer_message); //$mail->AltBody = strip_tags($answer_message);
$mail->send(); $mail->send();
echo 'Üzenet elküldve';
if($subpage != ''){ if($subpage != ''){
header('location:'.base_url().$lang.'/booking-finished/'.$subpage); header('location:'.base_url().$lang.'/booking-finished/'.$subpage);
} }
@@ -187,7 +185,10 @@ class User_model extends CI_Model {
} catch (Exception $e) { } catch (Exception $e) {
//header('location:'.base_url().'manage-applicants/?email-sent=false&error='.$mail->ErrorInfo); //header('location:'.base_url().'manage-applicants/?email-sent=false&error='.$mail->ErrorInfo);
log_message('error', 'sendEmail FAILED: ' . $mail->ErrorInfo); log_message('error', 'sendEmail FAILED: ' . $mail->ErrorInfo);
echo "Üzenetküldési hiba. Mailer Error: {$mail->ErrorInfo}"; // Do NOT echo the mailer error. This runs after the booking is
// saved and after the redirect header is queued, so it only leaks
// Hungarian internals into a response the guest never reads.
// The log_message() above is the record.
} }
} }
@@ -1,3 +1,144 @@
<style>
/* Capability grid. Scoped with a ws- prefix so it cannot leak into the other
admin forms. Colours follow the existing admin palette: #9e8462 borders and
headings, #cacaca body text on the dark background (see .tableClass). */
.wsCapabilityRow {
max-width: 760px;
margin: 0 auto 20px auto;
}
.wsSectionTitle {
font-size: 15px;
text-transform: uppercase;
color: #9e8462;
text-align: center;
margin-bottom: 8px;
}
.wsHint {
font-size: 12px;
line-height: 1.6;
color: #cacaca;
text-align: center;
margin: 0 auto 12px auto;
max-width: 700px;
}
.wsToolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.wsCount {
font-size: 13px;
color: #cacaca;
}
/* main.css styles every <button> with height:3.5em, letter-spacing:.35em,
font-weight:900 and an inset #585858 box-shadow, and forces the colour with
!important - so each of those has to be undone explicitly here. */
.wsMiniBtn {
background: transparent;
border: 0;
box-shadow: inset 0 0 0 1px #9e8462;
color: #9e8462 !important;
border-radius: 20px;
height: auto;
line-height: 1.4;
padding: 5px 16px;
font-size: 12px;
font-weight: normal;
letter-spacing: normal;
text-transform: none;
cursor: pointer;
}
.wsMiniBtn:hover {
background: #9e8462;
color: #fff !important;
}
.wsBox {
max-height: 360px;
overflow-y: auto;
border: 1px solid #9e8462;
background: rgba(0, 0, 0, 0.45);
padding: 10px 14px;
text-align: left;
}
/* The shared .tableClass centres itself with a 30px margin, which stacks badly
when several of them sit inside one scroll box. */
.wsTable {
width: 100%;
margin: 0 0 14px 0;
}
.wsTable th {
color: #9e8462;
background: transparent;
border: 0;
border-bottom: 1px solid #9e8462;
text-align: left;
padding: 6px 4px;
font-size: 13px;
text-transform: uppercase;
}
.wsTable td {
border: 0;
border-bottom: 1px solid rgba(158, 132, 98, 0.25);
padding: 5px 4px;
text-align: left;
vertical-align: middle;
line-height: 1.35;
color: #cacaca;
font-size: 14px;
}
.wsTable .wsCheckCell {
width: 34px;
text-align: center;
}
/* modules.css already re-enables the native checkbox for .tableClass tables
(main.css hides every checkbox with opacity:0 and draws a fake one via
"+ label:before", which cannot work when the label sits in another cell).
That rule outranks a plain .wsTable selector, so match its shape rather than
fight it - only accent-color and height are added on top. */
.wsTable > tbody > tr > td > input[type="checkbox"] {
height: 18px;
accent-color: #9e8462;
cursor: pointer;
}
/* The global label rule carries a 12.6px bottom margin, which was inflating
every row to ~54px. */
.wsNameCell label {
display: block;
cursor: pointer;
line-height: 1.35;
margin: 0;
padding: 0;
font-weight: normal;
}
.wsId {
color: #7d7d7d;
font-size: 12px;
margin-left: 4px;
}
@media screen and (max-width: 600px) {
.wsBox { max-height: 300px; padding: 8px; }
.wsTable td { font-size: 13px; }
}
</style>
<div class="mainContentContainer" style="top:100px"> <div class="mainContentContainer" style="top:100px">
<div class="pageTitle"><?php echo $pageTitle;?></div> <div class="pageTitle"><?php echo $pageTitle;?></div>
<form method="post" action="<?php echo base_url();?>workers/worker-process" enctype="multipart/form-data"> <form method="post" action="<?php echo base_url();?>workers/worker-process" enctype="multipart/form-data">
@@ -72,6 +213,76 @@
<label class="formTitle">ntfy Topic</label> <label class="formTitle">ntfy Topic</label>
<input type="text" class="formInputBox" name="ntfy_topic" value="<?php echo htmlspecialchars(isset($selectedItem->ntfy_topic) ? $selectedItem->ntfy_topic : '', ENT_QUOTES, 'UTF-8');?>"> <input type="text" class="formInputBox" name="ntfy_topic" value="<?php echo htmlspecialchars(isset($selectedItem->ntfy_topic) ? $selectedItem->ntfy_topic : '', ENT_QUOTES, 'UTF-8');?>">
</div> </div>
<div class="formRow wsCapabilityRow">
<div class="wsSectionTitle">Végezhető szolgáltatások</div>
<div class="wsHint">
Csak a bepipált szolgáltatásokat ajánlja fel a foglalási rendszer ennél a dolgozónál.
Ha <strong>mindegyik</strong> be van pipálva, a dolgozó korlátozás nélkül dolgozik, és a
később felvett új szolgáltatásokat is automatikusan végezheti. Ha akár egyet is kiveszel,
a dolgozó korlátozott lesz, és az ezután létrehozott új szolgáltatásokat külön be kell
majd pipálni neki.
</div>
<?php
// No stored rows = unrestricted, so show every box ticked. worker_services
// is written back the same way: all ticked saves nothing at all.
$wsIds = isset($workerServiceIds) && is_array($workerServiceIds) ? $workerServiceIds : array();
$wsUnrestricted = empty($wsIds);
$wsHasServices = isset($categoryServices) && is_array($categoryServices) && count($categoryServices) > 0;
if($wsHasServices){
// Group first so each heading can be rendered as its own table.
$wsGroups = array();
foreach($categoryServices as $svc){
$wsGroups[$svc->service_type.' - '.$svc->service_category_no][] = $svc;
}
?>
<div class="wsToolbar">
<span class="wsCount" id="wsCount"></span>
<button type="button" class="wsMiniBtn" onclick="wsSetAll(true)">Mindet bepipál</button>
<button type="button" class="wsMiniBtn" onclick="wsSetAll(false)">Mindet kivesz</button>
</div>
<div class="wsBox">
<?php
foreach($wsGroups as $wsHeading => $wsRows){
?>
<table class="tableClass wsTable">
<tr>
<th colspan="2"><?php echo htmlspecialchars($wsHeading, ENT_QUOTES, 'UTF-8');?></th>
</tr>
<?php
foreach($wsRows as $svc){
$checked = $wsUnrestricted || in_array((int)$svc->service_id, $wsIds, TRUE);
$svcName = $svc->service_name_hu !== '' ? $svc->service_name_hu : $svc->service_name_no;
?>
<tr>
<td class="wsCheckCell">
<input type="checkbox" class="wsService" id="wsService<?php echo (int)$svc->service_id;?>" name="worker_services[]" value="<?php echo (int)$svc->service_id;?>"<?php echo $checked ? ' checked' : '';?>>
</td>
<td class="wsNameCell">
<label for="wsService<?php echo (int)$svc->service_id;?>">
<?php echo htmlspecialchars($svcName, ENT_QUOTES, 'UTF-8');?>
<span class="wsId">#<?php echo (int)$svc->service_id;?></span>
</label>
</td>
</tr>
<?php
}
?>
</table>
<?php
}
?>
</div>
<?php
}
else{
?>
<div class="wsHint">Ehhez a kategóriához nincs szolgáltatás.</div>
<?php
}
?>
</div>
<div class="formRow"> <div class="formRow">
<input type="submit" class="submitButton" name="updateWorker" value="Módosítás"> <input type="submit" class="submitButton" name="updateWorker" value="Módosítás">
<a href="<?php echo base_url();?>workers" class="cancelButton">Mégsem</a> <a href="<?php echo base_url();?>workers" class="cancelButton">Mégsem</a>
@@ -79,3 +290,38 @@
</form> </form>
</div> </div>
<script>
// Keeps the counter honest and powers the two bulk buttons. All ticked is
// meaningful state, not just a convenience: the controller stores it as
// "no restriction" so the worker keeps getting future services too.
function wsRefreshCount(){
var boxes = document.querySelectorAll('.wsService');
var ticked = document.querySelectorAll('.wsService:checked').length;
var el = document.getElementById('wsCount');
if(!el){ return; }
if(boxes.length > 0 && ticked === boxes.length){
el.innerHTML = ticked + ' / ' + boxes.length + ' &ndash; korlátozás nélkül';
}
else{
el.innerHTML = ticked + ' / ' + boxes.length + ' kiválasztva';
}
}
function wsSetAll(state){
var boxes = document.querySelectorAll('.wsService');
for(var i = 0; i < boxes.length; i++){
boxes[i].checked = state;
}
wsRefreshCount();
}
document.addEventListener('DOMContentLoaded', function(){
var boxes = document.querySelectorAll('.wsService');
for(var i = 0; i < boxes.length; i++){
boxes[i].addEventListener('change', wsRefreshCount);
}
wsRefreshCount();
});
</script>
@@ -100,6 +100,21 @@
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg"> <img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
</div> </div>
</div> </div>
<div class="dolgozo-nev">
<hr>
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
</div>
<div class="bemutatkozas-dolgozo no-gallery">
<div class="bem-dolgozo-bal">
<p>Amarildo here, originally from Albania. I have more than six years of experience as a barber, and throughout my career I have worked in both Greece and England. I have been living in Norway for the past three years, and I am now happy to join the Studio Beve team.</p>
<p>I specialise in both modern and classic haircuts, as well as skin fades and taper fades. Precision and attention to detail are very important to me, and I always strive to achieve the best possible result for every client.</p>
<p>Since I started working as a barber, my profession has become a true passion of mine. I continuously develop my skills, learn new techniques, and keep myself up to date with the latest trends and styles.</p>
<p>For me, barbering is not simply about giving someone a haircut. It is important to create a style that truly suits each individual and to make sure every client leaves my chair feeling confident and satisfied.</p>
<p>I am excited to bring the experience I have gained over the years, my dedication to the profession, and my creativity to Studio Beve, and I look forward to welcoming you to my chair soon.</p>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -76,6 +76,21 @@
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg"> <img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
</div> </div>
</div> </div>
<div class="dolgozo-nev">
<hr>
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
</div>
<div class="bemutatkozas-dolgozo no-gallery">
<div class="bem-dolgozo-bal">
<p>Amarildo vagyok, és Albániából érkeztem. Több mint hatéves tapasztalattal rendelkezem barberként, pályafutásom során Görögországban és Angliában is dolgoztam. Három éve élek Norvégiában, és most örömmel csatlakozom a Studio Beve csapatához.</p>
<p>Modern és klasszikus hajvágásokra, valamint skin fade és taper fade technikákra specializálódtam. A munkámban fontos számomra a precizitás és a részletekre való odafigyelés, és mindig arra törekszem, hogy minden vendégem számára a lehető legjobb eredményt érjem el.</p>
<p>Amióta barberként dolgozom, a szakmám valódi szenvedélyemmé vált. Folyamatosan fejlesztem a tudásomat, új technikákat tanulok, és igyekszem naprakész maradni a legújabb trendekkel és stílusokkal.</p>
<p>Számomra a barbering nem egyszerűen egy hajvágás elkészítéséről szól. Fontos, hogy olyan stílust alakítsak ki, amely valóban illik az adott vendéghez, és hogy mindenki magabiztosan és elégedetten távozzon a székemből.</p>
<p>Örömmel hozom a Studio Bevébe az elmúlt években megszerzett tapasztalataimat, a szakmám iránti elkötelezettségemet és a kreativitásomat, és várom, hogy hamarosan téged is üdvözölhesselek a székemben.</p>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -77,6 +77,21 @@
<img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg"> <img src="<?php echo SITEURL;?>assets/img/barber/evelin/4.jpg">
</div> </div>
</div> </div>
<div class="dolgozo-nev">
<hr>
<img src="<?php echo SITEURL;?>assets/img/barber/amarildo/amarildo.jpg" class="dolgozo-kep">
<h4>AMARILDO Champimpi <span>- Barber</span></h4>
</div>
<div class="bemutatkozas-dolgozo no-gallery">
<div class="bem-dolgozo-bal">
<p>Jeg heter Amarildo og kommer opprinnelig fra Albania. Jeg har mer enn seks års erfaring som barber, og gjennom karrieren min har jeg jobbet både i Hellas og England. Jeg har bodd i Norge de siste tre årene, og nå er jeg glad for å bli en del av teamet hos Studio Beve.</p>
<p>Jeg spesialiserer meg på både moderne og klassiske hårklipper, samt skin fade og taper fade. Presisjon og sans for detaljer er svært viktig for meg, og jeg streber alltid etter å oppnå best mulig resultat for hver enkelt kunde.</p>
<p>Siden jeg begynte å jobbe som barber, har yrket blitt en ekte lidenskap for meg. Jeg utvikler meg kontinuerlig, lærer nye teknikker og holder meg oppdatert på de nyeste trendene og stilene.</p>
<p>For meg handler barberfaget ikke bare om å klippe håret. Det er viktig å skape en stil som virkelig passer den enkelte, og sørge for at hver kunde forlater stolen med en følelse av selvtillit og tilfredshet.</p>
<p>Jeg gleder meg til å ta med meg erfaringen jeg har opparbeidet gjennom årene, engasjementet mitt for faget og kreativiteten min til Studio Beve, og jeg ser frem til å ønske deg velkommen i stolen min.</p>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -7,7 +7,10 @@
'after_hours' => 'The treatment would not finish before closing time. Please choose an earlier time.', 'after_hours' => 'The treatment would not finish before closing time. Please choose an earlier time.',
'too_far' => 'Bookings can only be made up to 3 months in advance.', 'too_far' => 'Bookings can only be made up to 3 months in advance.',
'category_mismatch' => 'The selected staff member does not perform the chosen services. Please start again.', 'category_mismatch' => 'The selected staff member does not perform the chosen services. Please start again.',
'service_not_offered' => 'The selected staff member does not perform all of the chosen treatments. Please pick another staff member or change the treatments.',
'single_service_only' => 'Only one treatment can be booked at a time. Please select just one.', 'single_service_only' => 'Only one treatment can be booked at a time. Please select just one.',
'invalid_email' => 'That e-mail address does not look valid. Please check it - your confirmation and the link to manage your booking are sent there.',
'cutoff_passed' => 'Your booking is within 24 hours and can no longer be changed or cancelled. Please contact the salon.',
'no_service' => 'No service was selected. Please choose at least one service.', 'no_service' => 'No service was selected. Please choose at least one service.',
'default' => 'Something went wrong with your booking. Please try again.', 'default' => 'Something went wrong with your booking. Please try again.',
); );
@@ -17,12 +20,24 @@
: $bookingErrorMessages['default']; : $bookingErrorMessages['default'];
$backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber';
$backUrl = SITEURL.'en/booking/'.$backSubpage;
// _booking_error() passes a back URL when the error came from the
// manage-booking flow; that guest must return to their own booking page
// rather than being dropped into an empty new-booking form.
$isManageError = isset($bookingBackUrl) && $bookingBackUrl !== '';
$backUrl = $isManageError ? $bookingBackUrl : SITEURL.'en/booking/'.$backSubpage;
$backLabel = $isManageError ? 'Back to your booking' : 'Back to booking';
if(isset($bookingErrorCode) && $bookingErrorCode === 'slot_taken'){
$errorTitle = 'This time is no longer available';
} else {
$errorTitle = $isManageError ? 'Your booking could not be changed' : 'Your booking could not be completed';
}
?> ?>
<div class="bookingContainer" style="min-height:500px;"> <div class="bookingContainer" style="min-height:500px;">
<div class="bookingThankYouTitle">This time is no longer available</div> <div class="bookingThankYouTitle"><?php echo $errorTitle;?></div>
<div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div> <div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div>
<div style="text-align:center; margin-top:30px;"> <div style="text-align:center; margin-top:30px;">
<a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;">Back to booking</a> <a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;"><?php echo $backLabel;?></a>
</div> </div>
</div> </div>
@@ -7,7 +7,10 @@
'after_hours' => 'A kezelés nem érne véget zárásig. Kérjük, válasszon korábbi időpontot.', 'after_hours' => 'A kezelés nem érne véget zárásig. Kérjük, válasszon korábbi időpontot.',
'too_far' => 'Foglalás legfeljebb 3 hónappal előre adható le.', 'too_far' => 'Foglalás legfeljebb 3 hónappal előre adható le.',
'category_mismatch' => 'A kiválasztott kolléga nem végzi a kiválasztott szolgáltatásokat. Kérjük, válasszon újra.', 'category_mismatch' => 'A kiválasztott kolléga nem végzi a kiválasztott szolgáltatásokat. Kérjük, válasszon újra.',
'service_not_offered' => 'A kiválasztott kolléga nem végzi el az összes kiválasztott szolgáltatást. Kérjük, válasszon másik kollégát, vagy módosítsa a szolgáltatásokat.',
'single_service_only' => 'Egyszerre csak egy kezelés foglalható. Kérjük, csak egyet válasszon.', 'single_service_only' => 'Egyszerre csak egy kezelés foglalható. Kérjük, csak egyet válasszon.',
'invalid_email' => 'Az e-mail cím nem tűnik érvényesnek. Kérjük, ellenőrizze - a visszaigazolást és a foglalás kezelésére szolgáló linket erre a címre küldjük.',
'cutoff_passed' => 'A foglalás 24 órán belül van, ezért már nem módosítható és nem törölhető. Kérjük, vedd fel a kapcsolatot a szalonnal.',
'no_service' => 'Nem választott ki szolgáltatást. Kérjük, válasszon legalább egyet.', 'no_service' => 'Nem választott ki szolgáltatást. Kérjük, válasszon legalább egyet.',
'default' => 'Hiba történt a foglalás során. Kérjük, próbálja újra.', 'default' => 'Hiba történt a foglalás során. Kérjük, próbálja újra.',
); );
@@ -17,12 +20,24 @@
: $bookingErrorMessages['default']; : $bookingErrorMessages['default'];
$backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber';
$backUrl = SITEURL.'hu/booking/'.$backSubpage;
// _booking_error() passes a back URL when the error came from the
// manage-booking flow; that guest must return to their own booking page
// rather than being dropped into an empty new-booking form.
$isManageError = isset($bookingBackUrl) && $bookingBackUrl !== '';
$backUrl = $isManageError ? $bookingBackUrl : SITEURL.'hu/booking/'.$backSubpage;
$backLabel = $isManageError ? 'Vissza a foglalásodhoz' : 'Vissza a foglaláshoz';
if(isset($bookingErrorCode) && $bookingErrorCode === 'slot_taken'){
$errorTitle = 'Ez az időpont már nem foglalható';
} else {
$errorTitle = $isManageError ? 'A foglalást nem sikerült módosítani' : 'A foglalást nem sikerült véglegesíteni';
}
?> ?>
<div class="bookingContainer" style="min-height:500px;"> <div class="bookingContainer" style="min-height:500px;">
<div class="bookingThankYouTitle">Ez az időpont már nem foglalható</div> <div class="bookingThankYouTitle"><?php echo $errorTitle;?></div>
<div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div> <div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div>
<div style="text-align:center; margin-top:30px;"> <div style="text-align:center; margin-top:30px;">
<a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;">Vissza a foglaláshoz</a> <a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;"><?php echo $backLabel;?></a>
</div> </div>
</div> </div>
@@ -7,7 +7,10 @@
'after_hours' => 'Behandlingen rekker ikke å bli ferdig innen stengetid. Vennligst velg et tidligere tidspunkt.', 'after_hours' => 'Behandlingen rekker ikke å bli ferdig innen stengetid. Vennligst velg et tidligere tidspunkt.',
'too_far' => 'Du kan kun bestille time inntil 3 måneder frem i tid.', 'too_far' => 'Du kan kun bestille time inntil 3 måneder frem i tid.',
'category_mismatch' => 'Den valgte medarbeideren utfører ikke de valgte tjenestene. Vennligst velg på nytt.', 'category_mismatch' => 'Den valgte medarbeideren utfører ikke de valgte tjenestene. Vennligst velg på nytt.',
'service_not_offered' => 'Den valgte medarbeideren utfører ikke alle de valgte behandlingene. Vennligst velg en annen medarbeider eller endre behandlingene.',
'single_service_only' => 'Du kan kun bestille én behandling om gangen. Vennligst velg kun én.', 'single_service_only' => 'Du kan kun bestille én behandling om gangen. Vennligst velg kun én.',
'invalid_email' => 'E-postadressen ser ikke ut til å være gyldig. Vennligst kontroller den - bekreftelsen og lenken for å endre bestillingen sendes dit.',
'cutoff_passed' => 'Bestillingen er innen 24 timer og kan ikke lenger endres eller avbestilles. Vennligst ta kontakt med salongen.',
'no_service' => 'Du har ikke valgt noen tjeneste. Vennligst velg minst én tjeneste.', 'no_service' => 'Du har ikke valgt noen tjeneste. Vennligst velg minst én tjeneste.',
'default' => 'Noe gikk galt med bestillingen. Vennligst prøv igjen.', 'default' => 'Noe gikk galt med bestillingen. Vennligst prøv igjen.',
); );
@@ -17,12 +20,24 @@
: $bookingErrorMessages['default']; : $bookingErrorMessages['default'];
$backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber';
$backUrl = SITEURL.'no/booking/'.$backSubpage;
// _booking_error() passes a back URL when the error came from the
// manage-booking flow; that guest must return to their own booking page
// rather than being dropped into an empty new-booking form.
$isManageError = isset($bookingBackUrl) && $bookingBackUrl !== '';
$backUrl = $isManageError ? $bookingBackUrl : SITEURL.'no/booking/'.$backSubpage;
$backLabel = $isManageError ? 'Tilbake til bestillingen din' : 'Tilbake til bestilling';
if(isset($bookingErrorCode) && $bookingErrorCode === 'slot_taken'){
$errorTitle = 'Tidspunktet er ikke lenger tilgjengelig';
} else {
$errorTitle = $isManageError ? 'Bestillingen kunne ikke endres' : 'Bestillingen kunne ikke fullføres';
}
?> ?>
<div class="bookingContainer" style="min-height:500px;"> <div class="bookingContainer" style="min-height:500px;">
<div class="bookingThankYouTitle">Tidspunktet er ikke lenger tilgjengelig</div> <div class="bookingThankYouTitle"><?php echo $errorTitle;?></div>
<div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div> <div class="bookingThankYouMessageTitle"><?php echo $shownError;?></div>
<div style="text-align:center; margin-top:30px;"> <div style="text-align:center; margin-top:30px;">
<a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;">Tilbake til bestilling</a> <a href="<?php echo $backUrl;?>" style="display:inline-block; padding:12px 28px; background:#000; color:#fff; text-decoration:none; border-radius:5px; font-size:16px;"><?php echo $backLabel;?></a>
</div> </div>
</div> </div>
@@ -177,7 +177,7 @@
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingEmailTitle">E-mail:</label> <label class="bookingEmailTitle">E-mail:</label>
<input type="text" class="bookingEmail" name="guest_email" value="" required> <input type="email" class="bookingEmail" name="guest_email" value="" required>
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingPhoneTitle">Phone:</label> <label class="bookingPhoneTitle">Phone:</label>
@@ -364,12 +364,19 @@
} }
function getAvailableWorkersByServiceCategorySlug(categorySlug){ function getAvailableWorkersByServiceCategorySlug(categorySlug){
// Only workers who can perform EVERY ticked service may be offered.
// The checkbox id IS the service_id (see the service list markup).
var selectedServiceIds = $('.service:checked').map(function(){
return this.id;
}).get();
$.ajax({ $.ajax({
url: '<?php echo base_url();?>ajax', url: '<?php echo base_url();?>ajax',
type: 'POST', type: 'POST',
data: { data: {
action:'getAvailableWorkersByServiceCategory', action:'getAvailableWorkersByServiceCategory',
serv_cat_slug:categorySlug serv_cat_slug:categorySlug,
service_ids:selectedServiceIds
}, },
error: function() { error: function() {
}, },
@@ -178,7 +178,7 @@
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingEmailTitle">E-mail:</label> <label class="bookingEmailTitle">E-mail:</label>
<input type="text" class="bookingEmail" name="guest_email" value="" required> <input type="email" class="bookingEmail" name="guest_email" value="" required>
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingPhoneTitle">Telefon:</label> <label class="bookingPhoneTitle">Telefon:</label>
@@ -365,12 +365,19 @@
} }
function getAvailableWorkersByServiceCategorySlug(categorySlug){ function getAvailableWorkersByServiceCategorySlug(categorySlug){
// Only workers who can perform EVERY ticked service may be offered.
// The checkbox id IS the service_id (see the service list markup).
var selectedServiceIds = $('.service:checked').map(function(){
return this.id;
}).get();
$.ajax({ $.ajax({
url: '<?php echo base_url();?>ajax', url: '<?php echo base_url();?>ajax',
type: 'POST', type: 'POST',
data: { data: {
action:'getAvailableWorkersByServiceCategory', action:'getAvailableWorkersByServiceCategory',
serv_cat_slug:categorySlug serv_cat_slug:categorySlug,
service_ids:selectedServiceIds
}, },
error: function() { error: function() {
}, },
@@ -178,7 +178,7 @@ if (is_array($services) && count($services) > 0) {
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingEmailTitle">E-mail:</label> <label class="bookingEmailTitle">E-mail:</label>
<input type="text" class="bookingEmail" name="guest_email" value="" required> <input type="email" class="bookingEmail" name="guest_email" value="" required>
</div> </div>
<div class="formRow"> <div class="formRow">
<label class="bookingPhoneTitle">Telefon:</label> <label class="bookingPhoneTitle">Telefon:</label>
@@ -363,12 +363,19 @@ if (is_array($services) && count($services) > 0) {
} }
function getAvailableWorkersByServiceCategorySlug(categorySlug){ function getAvailableWorkersByServiceCategorySlug(categorySlug){
// Only workers who can perform EVERY ticked service may be offered.
// The checkbox id IS the service_id (see the service list markup).
var selectedServiceIds = $('.service:checked').map(function(){
return this.id;
}).get();
$.ajax({ $.ajax({
url: '<?php echo base_url();?>ajax', url: '<?php echo base_url();?>ajax',
type: 'POST', type: 'POST',
data: { data: {
action:'getAvailableWorkersByServiceCategory', action:'getAvailableWorkersByServiceCategory',
serv_cat_slug:categorySlug serv_cat_slug:categorySlug,
service_ids:selectedServiceIds
}, },
error: function() { error: function() {
}, },
+28 -2
View File
@@ -30,6 +30,7 @@
'back' => 'Tilbake', 'back' => 'Tilbake',
'send' => 'Bekreft endring', 'send' => 'Bekreft endring',
'no_times' => 'Ingen ledige tider denne dagen!', 'no_times' => 'Ingen ledige tider denne dagen!',
'no_workers' => 'Ingen medarbeidere utfører alle de valgte tjenestene.',
'expired_msg' => 'Denne bestillingen er allerede passert og kan ikke endres.', 'expired_msg' => 'Denne bestillingen er allerede passert og kan ikke endres.',
'cutoff_msg' => 'Bestillingen er innen 24 timer og kan ikke endres eller avbestilles.', 'cutoff_msg' => 'Bestillingen er innen 24 timer og kan ikke endres eller avbestilles.',
), ),
@@ -53,6 +54,7 @@
'back' => 'Vissza', 'back' => 'Vissza',
'send' => 'Módosítás megerősítése', 'send' => 'Módosítás megerősítése',
'no_times' => 'Ezen a napon nincs szabad időpont!', 'no_times' => 'Ezen a napon nincs szabad időpont!',
'no_workers' => 'Egyik kolléga sem végzi el az összes kiválasztott szolgáltatást.',
'expired_msg' => 'Ez a foglalás már elmúlt, nem módosítható.', 'expired_msg' => 'Ez a foglalás már elmúlt, nem módosítható.',
'cutoff_msg' => 'A foglalás 24 órán belül van, nem módosítható vagy törölhető.', 'cutoff_msg' => 'A foglalás 24 órán belül van, nem módosítható vagy törölhető.',
), ),
@@ -76,6 +78,7 @@
'back' => 'Back', 'back' => 'Back',
'send' => 'Confirm changes', 'send' => 'Confirm changes',
'no_times' => 'There is no available time on this day!', 'no_times' => 'There is no available time on this day!',
'no_workers' => 'No staff member performs all of the selected services.',
'expired_msg' => 'This booking has already passed and cannot be modified.', 'expired_msg' => 'This booking has already passed and cannot be modified.',
'cutoff_msg' => 'Your booking is within 24 hours and can no longer be changed or cancelled.', 'cutoff_msg' => 'Your booking is within 24 hours and can no longer be changed or cancelled.',
), ),
@@ -246,6 +249,7 @@
onsubmit="return confirm('<?php echo addslashes($l['cancel_confirm']); ?>');"> onsubmit="return confirm('<?php echo addslashes($l['cancel_confirm']); ?>');">
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token); ?>"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($token); ?>">
<input type="hidden" name="lang" value="<?php echo htmlspecialchars($lang); ?>"> <input type="hidden" name="lang" value="<?php echo htmlspecialchars($lang); ?>">
<input type="hidden" name="subpage" value="<?php echo htmlspecialchars($subpage); ?>">
<button type="submit" class="manage-cancel-btn"><?php echo $l['cancel_btn']; ?></button> <button type="submit" class="manage-cancel-btn"><?php echo $l['cancel_btn']; ?></button>
</form> </form>
</div> </div>
@@ -445,6 +449,7 @@
<script> <script>
var noTimesMsg = '<?php echo addslashes($l['no_times']); ?>'; var noTimesMsg = '<?php echo addslashes($l['no_times']); ?>';
var noWorkersMsg = '<?php echo addslashes($l['no_workers']); ?>';
$(document).ready(function(){ $(document).ready(function(){
recalcServices(); recalcServices();
@@ -543,16 +548,30 @@
} }
function getAvailableWorkersByServiceCategorySlug(categorySlug){ function getAvailableWorkersByServiceCategorySlug(categorySlug){
// Same rule as the new-booking form: only workers who can perform EVERY
// ticked service may be offered. The checkbox id IS the service_id.
var selectedServiceIds = $('.service:checked').map(function(){
return this.id;
}).get();
$.ajax({ $.ajax({
url: '<?php echo base_url(); ?>ajax', url: '<?php echo base_url(); ?>ajax',
type: 'POST', type: 'POST',
data: { action: 'getAvailableWorkersByServiceCategory', serv_cat_slug: categorySlug }, data: {
action: 'getAvailableWorkersByServiceCategory',
serv_cat_slug: categorySlug,
service_ids: selectedServiceIds
},
dataType: 'json', dataType: 'json',
}).done(function(result){ }).done(function(result){
$('#workerTable').html(result.workerListShow);
if(result.workers && result.workers.length > 0){ if(result.workers && result.workers.length > 0){
$('#workerTable').html(result.workerListShow);
setWorker(result.workers[0].worker_id); setWorker(result.workers[0].worker_id);
} }
else{
$('#workerTable').html('<div class="noWorkerMessage">' + noWorkersMsg + '</div>');
setWorker('');
}
}); });
} }
@@ -645,6 +664,13 @@
function setWorker(worker_id){ function setWorker(worker_id){
$('#worker_id').val(worker_id); $('#worker_id').val(worker_id);
$('.serviceBookingWorkerContainer').hide().html(''); $('.serviceBookingWorkerContainer').hide().html('');
// The worker (or the service set that produced this list) has changed, so
// a time picked for the previous one is stale. Clearing it also disables
// the submit button via the #booking_time change handler.
$('#booking_time').val('').trigger('change');
$('#serviceBookingTime').html('').hide();
$('.availableBookingTime').removeClass('selectedTime');
} }
</script> </script>
+5
View File
@@ -3364,6 +3364,11 @@ input, select, textarea {
} }
/* Worker bio with no photo strip alongside - let the text use the full width. */
.bemutatkozas-dolgozo.no-gallery .bem-dolgozo-bal{
width: 100%;
}
/* Treatments - Our Services */ /* Treatments - Our Services */
+9 -1
View File
@@ -3297,6 +3297,12 @@ input, select, textarea {
text-align: right; text-align: right;
/* was `margin: auto` - on a flex item that centres it on BOTH axes. */ /* was `margin: auto` - on a flex item that centres it on BOTH axes. */
margin: 0; margin: 0;
/* Optical alignment with the bio text. Both columns are flex-start, so
their boxes DO start level - but body text is 13pt/1.75em, which puts
6.5px of half-leading above the first line's glyphs, while an inline
image starts flush at the box top. Without this the strip reads as
sitting high. (1.75em - 1em) / 2 = 0.375em = that same 6.5px. */
padding-top: 0.375em;
} }
@@ -4488,7 +4494,9 @@ input, select, textarea {
.bemutatkozas-dolgozo{display: block;} .bemutatkozas-dolgozo{display: block;}
.bem-dolgozo-bal {width: 100%;} .bem-dolgozo-bal {width: 100%;}
.bem-dolgozo-jobb {width: 100%; float: none; text-align: center;} /* Container is display:block here, so the strip no longer sits
beside the text and the half-leading correction is unwanted. */
.bem-dolgozo-jobb {width: 100%; float: none; text-align: center; padding-top: 0;}
#czcategorylistblock .czcategorylist-wrapper .category_lists .one-half .category-content {width: 100%; } #czcategorylistblock .czcategorylist-wrapper .category_lists .one-half .category-content {width: 100%; }
.one-half{width:100%; float: none;} .one-half{width:100%; float: none;}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB