Sessions bookings. * * @var array */ private array $option_sessions = []; private string $date = ''; private string $time = ''; /** * @var mixed[] */ private array $options = []; /** * Data of Participant Fields * * @var mixed[] */ private array $participants = []; /** * Data of Booking Fields * * @var mixed[] */ private array $form = []; /** * Selected or assigned resources keyed by option ID. * * @var array */ private array $resources = []; private bool $resource_assignments_locked = false; private int $availability_excluded_booking_id = 0; /** * Data of Registration Fields * * @var mixed[] */ private array $registration = []; private string $coupon = ''; private Offering $offering; private int $skippable = 1; /** * @param mixed[] $data */ public function __construct(array $data = []) { // Booking ID $this->id = $data['id'] ?? Id::generate(); // General Settings $this->settings = Config::general(); // Booking Type $this->method = Arr::ternary($data, 'method', BookingMethods::SESSION_OPTION); // Sessions if (Arr::isset_and_array($data, 'sessions')) $this->setSessions($data['sessions']); if (Arr::isset_and_array($data, 'option_sessions')) $this->setOptionSessions($data['option_sessions']); // Offering if (Arr::isset_and_array($data, 'offering')) $this->setOffering(new Offering($data['offering'], $this->settings)); // Coupon if (isset($data['coupon'])) $this->setCoupon($data['coupon']); } public function __toString(): string { return json_encode([ 'id' => $this->id, 'sessions' => $this->sessions, 'option_sessions' => $this->option_sessions, 'date' => $this->date, 'time' => $this->time, 'options' => $this->options, 'resources' => $this->getResources(), 'form' => $this->form, 'participants' => $this->getParticipantRows(), 'registration' => $this->registration, 'offering' => $this->offering->id(), 'method' => $this->method, 'skippable' => $this->skippable, 'coupon' => $this->coupon, ]); } /** * @return string[] */ public function steps(): array { if ($this->method === BookingMethods::DATE_TIME_OPTION) return ['date', 'option', 'form', 'payment', 'thankyou']; if ($this->method === BookingMethods::OPTION_SESSION) return ['option', 'session', 'form', 'payment', 'thankyou']; return ['session', 'option', 'form', 'payment', 'thankyou']; } /** * @param string $step * @return bool */ public function skippable(string $step): bool { // Session Step if ($step === 'session') { // Sessions are already set if ($this->method === BookingMethods::OPTION_SESSION) { if ($this->hasSessionsForSelectedOptions()) return true; } else if ($this->hasSessions()) return true; // Offering Sessions $sessions = $this->offering->getAvailableSessions(2); // Only One Sessions if (count($sessions) === 1) { // Set Session $this->setSessions([ $sessions[0]->startDateTime(DateTimeFormat::tech()), ]); if ($this->method === BookingMethods::OPTION_SESSION && !$this->hasSessionsForSelectedOptions()) { return false; } if ($this->method === BookingMethods::OPTION_SESSION && count($this->getOptionErrors())) { return false; } return true; } } else if ($step === 'date') { // Date and time are already set if ($this->date && $this->time) return true; // Offering Sessions $sessions = $this->offering->getAvailableSessions(2); // Only One Sessions if (count($sessions) === 1) { // Set Date $this->setDate( $sessions[0]->startDate(DateFormat::tech()) ); $this->setTime( $sessions[0]->startTime(TimeFormat::tech()) ); return true; } } else if ($step === 'time') { // Time is already set if ($this->time) return true; // Offering Sessions $sessions = $this->offering->getAvailableSessions(2); // Only One Sessions if (count($sessions) === 1) { // Set Time $this->setTime( $sessions[0]->startTime(TimeFormat::tech()) ); return true; } } else if ($step === 'option') { // Information are already set if (count($this->options) && !count($this->getOptionErrors())) return true; // Options $options = $this->offering->getOptions() ->setSessions($this->getAllSessions()) ->available(); // Only one option with certain count if (count($options) === 1) { $option = reset($options); if ($option->minToBuy() === $option->maxToBuy()) { // Set Option $this->setOptions([ $option->id() => $option->minToBuy(), ]); return true; } } } else if ($step === 'form') { // Information are already set if ($this->isFormStepSubmittedOrPassed() && !count($this->getFormErrors())) return true; } return false; } private function hasSessionsForSelectedOptions(): bool { $has_options = false; foreach ($this->getOptions() as $option_id => $quantity) { if (absint($quantity) <= 0) continue; $has_options = true; if (!count($this->sessionsForOption((int) $option_id))) return false; } return $has_options; } private function isFormStepSubmittedOrPassed(): bool { $request = Request::get(); $requested = $request && isset($request->step) ? (string) $request->step : ''; if ($requested) return $this->isBeforeStep('form', $requested); return count($this->form) > 0 || count($this->participants) > 0 || count($this->registration) > 0; } public function setRequest(): self { // Booking Request $request = Request::get(); // No Request if (!$request || !isset($request->id)) return $this; return $this->byObject($request, true); } public function byRefId(string $id): ?self { $JSON = get_option($this->storageKey($id), null); // Keep already-started legacy payment flows working after the storage-key // migration. The payload must be a draft whose ID exactly matches the // requested reference before a legacy option can be used. if (!$JSON) $JSON = $this->legacyJsonByRefId($id); if (!$JSON) return null; return $this->byJson($JSON); } private function legacyJsonByRefId(string $id): ?string { $JSON = get_option($id, null); if (!is_string($JSON) || $JSON === '') return null; $data = json_decode(stripslashes($JSON), true); if (!is_array($data) || !isset($data['id']) || !is_string($data['id'])) return null; if (!hash_equals($id, $data['id'])) return null; return $JSON; } /** * @param string $JSON * @return self */ public function byJson(string $JSON): self { // Request Object $request = new stdClass(); // Data $data = json_decode(stripslashes($JSON), true); // Not Valid Array if (Arr::not($data)) return $this; // Populate Object foreach ($data as $key => $value) $request->{$key} = $value; // No Request if (!isset($request->id)) return $this; return $this->byObject($request); } /** * @param stdClass $data * @return self */ private function byObject(stdClass $data, bool $is_submitted_request = false): self { // Set Parameters foreach ($data as $key => $value) { if ($key === 'offering') $this->setOffering(new Offering($value, $this->settings)); else if ($key === 'id') $this->id = $value; else if ($key === 'sessions') $this->setSessions($value); else if ($key === 'option_sessions' && is_array($value)) $this->setOptionSessions($value); else if ($key === 'date') $this->setDate($value); else if ($key === 'time') $this->setTime($value); else if ($key === 'method') $this->setBookingMethod($value); else if ($key === 'options') $this->setOptions($value); else if ($key === 'resources') $this->setResources(is_array($value) ? $value : []); else if ($key === 'participants') $this->setParticipants($value); else if ($key === 'form') $this->setForm($value); else if ($key === 'registration') $this->setRegistration($value); else if ($key === 'skippable') $this->setSkippable($value); else if ($key === 'coupon') $this->setCoupon($value); } if ($is_submitted_request) $this->sanitizeSubmittedFields(); $requested_step = isset($data->step) ? (string) $data->step : ''; if ($this->method === BookingMethods::OPTION_SESSION && $requested_step === 'option') { $this->setSessions([]); $this->setOptionSessions([]); $this->setDate(''); $this->setTime(''); $this->setResources([]); } return $this; } private function sanitizeSubmittedFields(): void { if (!isset($this->offering)) return; [$fields] = $this->getOffering()->getBookingFields(); $booking_fields = isset($fields['booking']) && is_array($fields['booking']) ? $fields['booking'] : []; $participant_fields = isset($fields['participant']) && is_array($fields['participant']) ? $fields['participant'] : []; $this->form = CustomFields::sanitizeValues($booking_fields, $this->form); $participants = []; foreach ($this->participants as $participant_id => $participant) { if (!is_array($participant)) continue; $option_id = absint($participant['option_id'] ?? 0); $values = CustomFields::sanitizeValues($participant_fields, $participant); if ($option_id > 0) $values['option_id'] = $option_id; $participants[$participant_id] = $values; } $this->participants = $participants; $this->registration = [ 'username' => isset($this->registration['username']) ? sanitize_text_field((string) $this->registration['username']) : '', // Passwords must remain unchanged for authentication. 'password' => isset($this->registration['password']) && is_scalar($this->registration['password']) ? (string) $this->registration['password'] : '', ]; } public function getId(): string { return $this->id; } /** * Return total selected options without * considering the sessions. * * For example, if three sessions are selected and three * options, this function returns three and not nine. * * @return int */ public function getTotalOptions(): int { $options = $this->getOptions(); if (!count($options)) return 0; return array_sum($options); } public function getTotalParticipants(): int { return count($this->getParticipantRows()); } /** * @return mixed[] */ public function getRawParticipants(): array { $raw = []; foreach ($this->options as $option_id => $count) { for ($i = 1; $i <= $count; $i++) { $raw[] = [ 'option_id' => $option_id, ]; } } return $raw; } /** * @return string[] */ public function getAllSessions(): array { if ($this->usesOptionSessions()) { return $this->uniqueSessions($this->optionSessionUnion()); } return $this->legacySessions(); } /** * @return string[] */ private function legacySessions(): array { $dates = $this->getSessions(); // Standalone Date & Time if (!count($dates) && $this->getDate() && $this->getTime()) { $from = $this->offering->getTimeZone(); $to = new DateTimeZone('UTC'); // Date & Times are stored in offering timezone, so we need to convert them into UTC. $dates[] = (new DateTime($this->getDate() . ' ' . $this->getTime(), $from)) ->toTimezone(DateTimeFormat::tech(), $to); } return $this->uniqueSessions($dates); } public function hasSessions(): bool { return count($this->getAllSessions()) > 0; } /** * @return string|null */ public function getFirstSession(): ?string { $dates = $this->getAllSessions(); return $dates[0] ?? null; } /** * @param mixed[] $sessions * @return self */ public function setSessions(array $sessions): self { $this->sessions = $this->uniqueSessions($sessions); return $this; } /** * @return mixed[] */ public function getSessions(): array { return $this->sessions; } /** * @param mixed[] $option_sessions * @return self */ public function setOptionSessions(array $option_sessions): self { $clean = []; foreach ($option_sessions as $option_id => $sessions) { $option_id = absint($option_id); if ($option_id <= 0) continue; $sessions = is_array($sessions) ? $sessions : []; $clean[$option_id] = $this->uniqueSessions($sessions); } $this->option_sessions = $clean; return $this; } /** * @return array */ public function getOptionSessions(): array { return $this->option_sessions; } public function usesOptionSessions(): bool { return $this->method === BookingMethods::OPTION_SESSION && count($this->option_sessions) > 0; } /** * @return string[] */ public function sessionsForOption(int $option_id): array { $option_id = absint($option_id); if ($option_id <= 0) return []; if ($this->usesOptionSessions()) return $this->option_sessions[$option_id] ?? []; return $this->legacySessions(); } /** * @return array */ public function sessionsByOption(): array { $sessions = []; foreach ($this->getOptions() as $option_id => $quantity) { $option_id = absint($option_id); $quantity = absint($quantity); if ($option_id <= 0 || $quantity <= 0) continue; $option_sessions = $this->sessionsForOption($option_id); if (!count($option_sessions)) continue; $sessions[$option_id] = $option_sessions; } return $sessions; } /** * @return string[] */ private function optionSessionUnion(): array { $sessions = []; foreach ($this->sessionsByOption() as $option_sessions) { foreach ($option_sessions as $session) { $sessions[] = $session; } } return $sessions; } /** * @param mixed[] $sessions * @return string[] */ private function uniqueSessions(array $sessions): array { $clean = []; foreach ($sessions as $session) { if (!is_scalar($session)) continue; $session = sanitize_text_field((string) $session); if ($session === '' || !strtotime($session)) continue; $clean[] = $session; } return array_values(array_unique($clean)); } /** * @param string $date * @return self */ public function setDate(string $date): self { $this->date = $date; return $this; } public function getDate(): string { return $this->date; } /** * @param string $time * @return self */ public function setTime(string $time): self { $this->time = $time; return $this; } public function getTime(): string { return $this->time; } /** * @param Offering $offering * @return self */ public function setOffering(Offering $offering): self { $this->offering = $offering; return $this; } public function getOffering(): Offering { return $this->offering; } /** * Set Booking Method * * @param string $method * @return self */ public function setBookingMethod(string $method): self { $this->method = $method; return $this; } /** * Get Booking Method * * @return string */ public function getBookingMethod(): string { return $this->method; } /** * @param mixed[] $options * @return self */ public function setOptions(array $options): self { $clean = []; foreach ($options as $option_id => $quantity) { $option_id = absint($option_id); if ($option_id <= 0) continue; $clean[$option_id] = absint($quantity); } $this->options = $clean; return $this; } /** * @return array */ public function getOptions(): array { if (!isset($this->offering)) return $this->options; $options = []; foreach ($this->options as $option_id => $quantity) { $option = $this->getOffering()->getOptions()->get($option_id); if (!$option) continue; $options[$option_id] = absint($quantity); } return $options; } /** * @param mixed[] $resources * @return self */ public function setResources(array $resources): self { $clean = []; foreach ($resources as $option_id => $resource_id) { $option_id = absint($option_id); $resource_id = absint($resource_id); if ($option_id > 0 && $resource_id > 0) $clean[$option_id] = $resource_id; } $this->resources = $clean; return $this; } /** * @return array */ public function getResources(): array { if (isset($this->offering)) $this->assignAutomaticResources(); return $this->resources; } public function lockResourceAssignments(bool $refresh_assignments = true): self { if ($refresh_assignments && isset($this->offering)) $this->assignAutomaticResources(); $this->resource_assignments_locked = true; return $this; } public function setAvailabilityExcludedBookingId(int $booking_id): self { $this->availability_excluded_booking_id = absint($booking_id); return $this; } public function availabilityExcludedBookingId(): int { return $this->availability_excluded_booking_id; } public function getResourceIdForOption(int $option_id): ?int { $resources = $this->getResources(); $resource_id = absint($resources[$option_id] ?? 0); return $resource_id > 0 ? $resource_id : null; } /** * @return array, available_ids: int[]}> */ public function getResourceRequirements(?AvailabilityEngine $engine = null): array { $engine = $engine ?? AvailabilityEngine::snapshot(); $this->assignAutomaticResources($engine); $requirements = []; foreach ($this->selectedOptions() as $option_id => $quantity) { $option = $this->resourceOption($option_id); if (!$option || !$option->requiresResource()) continue; $available = $this->availableResourceIds($option, $this->requiredResourceCapacity($option, $quantity), $engine); $requirements[$option_id] = [ 'option' => $option, 'quantity' => $quantity, 'mode' => $option->resourceSelectionMode(), 'selected' => absint($this->resources[$option_id] ?? 0), 'choices' => $this->resourceChoices($available), 'available_ids' => $available, ]; } return $requirements; } public function hasOptions(): bool { return count($this->getOptions()) > 0; } public function getTotalSeats(): int { $options = $this->getOptions(); $seats = 0; foreach ($options as $option_id => $count) { $option = $this->getOffering()->getOptions()->get($option_id); if (!$option) continue; $seat = $option->seats(); $seats += max(1, $seat) * $count; } return $seats; } /** * @return mixed[] */ public function getOptionErrors(): array { // Errors $errors = []; $invalid_option_ids = $this->invalidSelectedOptionIds(); if (count($invalid_option_ids)) { $errors[] = esc_html__('One or more selected options are no longer available. Please refresh the page and choose again.', 'daytidy'); } if ($this->getTotalOptions() <= 0 && !count($invalid_option_ids)) { $errors[] = sprintf( // translators: Placeholder values. esc_html__('Please select at least one %s for booking.', 'daytidy'), strtolower(wp_strip_all_tags($this->offering->getTypeLabel('option'))) ); } $option_label = strtolower(wp_strip_all_tags($this->offering->getTypeLabel('option'))); $time_label = strtolower(wp_strip_all_tags($this->offering->getTypeLabel('time'))); $engine = AvailabilityEngine::snapshot(); foreach ($this->getOptions() as $id => $count) { $sessions = $this->sessionsForOption($id); $option = $this->getOffering() ->getOptions() ->setSessions($sessions) ->get($id); if (!$option) continue; $option->setAvailabilityExcludedBookingId($this->availabilityExcludedBookingId()); $option->setAvailabilityEngine($engine); if ($count > 0 && count($sessions) && $option->isUnavailable($count)) { $errors[] = sprintf( // translators: %1$s: Option title, %2$s: Time label, %3$s: Option label. esc_html__( 'The selected %1$s is no longer available for the selected %2$s. Please choose another %3$s or %2$s.', 'daytidy' ), esc_html($option->title()), esc_html($time_label), esc_html($option_label) ); continue; } if ($count < $option->minAllowed()) { // translators: Placeholder values. $errors[] = sprintf(esc_html__('You should book at least %1$d %2$s options.', 'daytidy'), $option->minAllowed(), $option->title()); } $defer_max_check = $this->method === BookingMethods::OPTION_SESSION && !count($sessions); $max_allowed = $defer_max_check && $option->hasMaxToBuy() ? $option->maxToBuy() : $option->maxAllowed(); if ((!$defer_max_check || $option->hasMaxToBuy()) && $count > $max_allowed) { // translators: Placeholder values. $errors[] = sprintf(esc_html__('You can book only %1$d %2$s options.', 'daytidy'), $max_allowed, $option->title()); } } return $errors; } public function getSubTotalAmount(): float { return (new PricingCalculator($this))->subtotal(); } /** * @return array */ public function getPricing(bool $check_coupon_usage = true, bool $validate_coupon = true): array { return (new PricingCalculator($this))->calculate($check_coupon_usage, $validate_coupon); } /** * @param mixed[] $participants * @return self */ public function setParticipants(array $participants): self { $this->participants = $participants; return $this; } /** * @return mixed[] */ public function getParticipants(): array { return $this->participants; } /** * @return mixed[] */ public function getParticipantRows(): array { return count($this->participants) ? $this->participants : $this->getRawParticipants(); } /** * @return mixed[] */ public function getMappedParticipants(): array { [$fields] = $this->getOffering()->getBookingFields(); $participant_fields = $fields['participant'] ?? []; $mapped = []; foreach ($this->getParticipantRows() as $participant_id => $participant) { $participant = is_array($participant) ? $participant : []; $option_id = (int) ($participant['option_id'] ?? 0); $option = $option_id ? OfferingOption::getById($this->offering, $option_id) : null; if ($option) { $mapped[$participant_id]['option'] = [ 'id' => 0, 'type' => 'text', 'label' => $this->offering->getTypeLabel('option'), 'system' => 'option', 'value' => esc_html($option->title()), ]; } $participant_values = CustomFields::pruneValues($participant_fields, $participant); foreach ($participant_fields as $field_id => $field) { // Invalid Field if (!is_numeric($field_id) || !$field) continue; if (!array_key_exists($field_id, $participant_values)) continue; // Value $value = $participant_values[$field_id]; // Mapped Value $v = $this->getMappedField($field_id, $field, $value); // No Value if (!count($v)) continue; // Field Key $key = isset($field['system']) && trim($field['system']) ? $field['system'] : $field_id; $mapped[$participant_id][$key] = $v; } } return $mapped; } /** * @param int $field_id * @param mixed[] $field * @param mixed $value * @return mixed[] */ public function getMappedField(int $field_id, array $field, $value): array { // Type $type = isset($field['type']) && trim($field['type']) ? $field['type'] : ''; // Field Label $label = isset($field['label']) && trim($field['label']) ? $field['label'] : ''; // Options $options = isset($field['options']) && is_array($field['options']) ? $field['options'] : []; // Agreement Field if ($type === 'agreement') { $page_id = $field['page'] ?: 0; $page = get_post($page_id); if ($page_id && $page instanceof WP_Post && $page->post_status === 'publish') { $label = sprintf($label, '' . $page->post_title . ''); $value = $value == 1 ? esc_html__('Yes', 'daytidy') : esc_html__('No', 'daytidy'); } } // Email Field if ($type === 'email' && is_email($value)) { $value = '' . $value . ''; } // Url Field else if ($type === 'url' && filter_var($value, FILTER_VALIDATE_URL)) { $value = '' . $value . ''; } // Select & Radio Fields else if (($type === 'select' || $type === 'radio') && count($options)) { $value = $options[$value] ?? $value; } // Checkbox Field else if ($type === 'checkbox' && count($options) && is_array($value)) { $rendered = []; foreach ($value as $v) { $rendered[] = $options[$v] ?? $v; } $value = $rendered; } // Date Field else if ($type === 'date') { $value = DateTime::render($value, DateFormat::wp(), $this->offering->getTimeZone()); } // Time Field else if ($type === 'time') { $value = DateTime::render($value, TimeFormat::wp(), $this->offering->getTimeZone()); } return [ 'id' => $field_id, 'type' => $type, 'label' => $label, 'system' => isset($field['system']) && trim($field['system']) ? $field['system'] : '', 'value' => $value, ]; } /** * @return mixed[] */ public function getFormErrors(): array { // Booking Module Config $config = $this->settings->module('booking'); // Fields [$fields] = $this->getOffering()->getBookingFields(); $participant_fields = $fields['participant'] ?? []; $booking_fields = $fields['booking'] ?? []; // Errors $errors = []; // Participants Form foreach ($this->participants as $participant_id => $participant) { $field_errors = CustomFields::validateValues($participant_fields, is_array($participant) ? $participant : []); if (count($field_errors)) $errors['participant'][$participant_id] = $field_errors; } // Booking Form $field_errors = CustomFields::validateValues($booking_fields, $this->form); if (count($field_errors)) $errors['form'] = $field_errors; // Resource Selection $resource_errors = $this->getResourceErrors(); if (count($resource_errors)) $errors['resources'] = $resource_errors; // Registration Form if (!get_current_user_id() && isset($config['registration']) && $config['registration'] && isset($config['userpass']) && $config['userpass'] === 'manual' ) { $username = isset($this->registration['username']) ? sanitize_text_field(trim($this->registration['username'])) : ''; // Validate the same password that is passed to wp_create_user(). $password = isset($this->registration['password']) && is_scalar($this->registration['password']) ? (string) $this->registration['password'] : ''; // Short Password if (strlen($password) < 8) { $errors['registration']['password'] = [ 'errors' => ['invalid'], 'message' => esc_html__('The password must be at least 8 characters long.', 'daytidy'), ]; } if (strlen($username) < 6 || strlen($username) > 20) { $errors['registration']['username'] = [ 'errors' => ['invalid'], 'message' => esc_html__('The username should be between 6 and 20 characters.', 'daytidy'), ]; } else if (!preg_match('/^\w{6,}$/', $username)) { $errors['registration']['username'] = [ 'errors' => ['invalid'], 'message' => esc_html__('Only letters, numbers, and underscores are allowed in the username.', 'daytidy'), ]; } else if (!validate_username($username)) { $errors['registration']['username'] = [ 'errors' => ['invalid'], 'message' => esc_html__('Username is not valid.', 'daytidy'), ]; } else if (username_exists($username)) { $errors['registration']['username'] = [ 'errors' => ['invalid'], 'message' => esc_html__('The selected username already exists, please choose a different one.', 'daytidy'), ]; } } return $errors; } /** * @return string[] */ public function getResourceErrors(): array { return $this->getResourceConflictErrors(); } /** * @return string[] */ public function getResourceConflictErrors(): array { $errors = []; $engine = AvailabilityEngine::snapshot(); $requirements = $this->getResourceRequirements($engine); $capacity_requirements = $this->resourceSessionCapacityRequirements($requirements); foreach ($requirements as $requirement) { $option = $requirement['option'] ?? null; if (!$option instanceof OfferingOption) continue; $available_ids = isset($requirement['available_ids']) && is_array($requirement['available_ids']) ? array_map('absint', $requirement['available_ids']) : []; $selected = absint($requirement['selected'] ?? 0); if (!count($available_ids)) { $errors[] = sprintf( // translators: Placeholder values. esc_html__('No resource is available for %s at the selected time.', 'daytidy'), '' . esc_html($option->title()) . '' ); continue; } $customer_choice = $option->resourceSelectionMode() === ResourceSelectionModes::CUSTOMER; if (!$selected || !in_array($selected, $available_ids, true)) { if ($customer_choice) { $errors[] = sprintf( // translators: Placeholder values. esc_html__('Please select an available resource for %s.', 'daytidy'), '' . esc_html($option->title()) . '' ); } else if ($this->resource_assignments_locked) { $errors[] = sprintf( // translators: Placeholder values. esc_html__('The selected resource for %s is no longer available at the selected time. Please choose another resource or time.', 'daytidy'), '' . esc_html($option->title()) . '' ); } continue; } foreach ($this->sessionsForOption($option->id()) as $session) { $required_capacity = absint($capacity_requirements[$selected][$session] ?? 1); $available_for_capacity = $engine->availableResourceIdsForSessions( $this->getOffering(), $option, [$session], $required_capacity, $this->availabilityExcludedBookingId() ); if (in_array($selected, $available_for_capacity, true)) continue; $errors[] = sprintf( // translators: Placeholder values. esc_html__('The selected resource for %s is no longer available at the selected time. Please choose another resource or time.', 'daytidy'), '' . esc_html($option->title()) . '' ); break; } } return $errors; } public function getResourceConflictMessage(): string { $errors = $this->getResourceConflictErrors(); return count($errors) ? implode(' ', $errors) : ''; } /** * @param mixed[] $form * @return self */ public function setForm(array $form): self { $this->form = $form; return $this; } /** * @return mixed[] */ public function getForm(): array { return $this->form; } /** * @return mixed[] */ public function getMappedForm(): array { [$fields] = $this->getOffering()->getBookingFields(); $booking_fields = $fields['booking'] ?? []; $mapped = []; $form_values = CustomFields::pruneValues($booking_fields, $this->form); foreach ($booking_fields as $field_id => $field) { // Invalid Field if (!is_numeric($field_id) || !$field) continue; if (!array_key_exists($field_id, $form_values)) continue; // Value $value = $form_values[$field_id]; // Mapped Value $v = $this->getMappedField($field_id, $field, $value); // No Value if (!count($v)) continue; // Field Key $key = isset($field['system']) && trim($field['system']) ? $field['system'] : $field_id; $mapped[$key] = $v; } foreach ($this->manualContactMappedFields() as $key => $field) { if (!empty($mapped[$key]['value'])) continue; $mapped[$key] = $field; } return $mapped; } /** * @return mixed[] */ private function manualContactMappedFields(): array { $fields = [ 'first_name' => [ 'type' => 'text', 'label' => esc_html__('First Name', 'daytidy'), 'value' => sanitize_text_field((string) ($this->form['_manual_first_name'] ?? '')), ], 'last_name' => [ 'type' => 'text', 'label' => esc_html__('Last Name', 'daytidy'), 'value' => sanitize_text_field((string) ($this->form['_manual_last_name'] ?? '')), ], 'email' => [ 'type' => 'email', 'label' => esc_html__('Email', 'daytidy'), 'value' => sanitize_email((string) ($this->form['_manual_email'] ?? '')), ], ]; foreach ($fields as $key => $field) { if ($field['value'] === '') unset($fields[$key]); else $fields[$key] += [ 'id' => 0, 'system' => $key, ]; } return $fields; } /** * @return array */ private function selectedOptions(): array { $selected = []; foreach ($this->getOptions() as $option_id => $quantity) { $option_id = absint($option_id); $quantity = absint($quantity); if ($option_id > 0 && $quantity > 0) $selected[$option_id] = $quantity; } return $selected; } /** * @return int[] */ private function invalidSelectedOptionIds(): array { if (!isset($this->offering)) return []; $invalid = []; foreach ($this->options as $option_id => $quantity) { $option_id = absint($option_id); $quantity = absint($quantity); if ($option_id <= 0 || $quantity <= 0) continue; if ($this->getOffering()->getOptions()->get($option_id)) continue; $invalid[] = $option_id; } return array_values(array_unique($invalid)); } private function resourceOption(int $option_id): ?OfferingOption { if (!isset($this->offering)) return null; return $this->getOffering() ->getOptions() ->setSessions($this->sessionsForOption($option_id)) ->get($option_id); } /** * @return int[] */ private function availableResourceIds(OfferingOption $option, int $required_capacity, AvailabilityEngine $engine): array { $sessions = $this->sessionsForOption($option->id()); if (!count($sessions)) return []; return $engine->availableResourceIdsForSessions( $this->getOffering(), $option, $sessions, $required_capacity, $this->availabilityExcludedBookingId() ); } private function assignAutomaticResources(?AvailabilityEngine $engine = null): void { if ($this->resource_assignments_locked) return; if (!isset($this->offering)) return; $engine = $engine ?? AvailabilityEngine::snapshot(); $selected_options = $this->selectedOptions(); foreach ($this->resources as $option_id => $resource_id) { $option_id = absint($option_id); if (!isset($selected_options[$option_id])) { unset($this->resources[$option_id]); continue; } $option = $this->resourceOption($option_id); if (!$option || !$option->requiresResource()) unset($this->resources[$option_id]); } $requirements = $this->resourceAssignmentRequirements($selected_options, $engine); $fixed_assignments = []; $automatic_option_ids = []; foreach ($requirements as $option_id => $requirement) { $selected = absint($this->resources[$option_id] ?? 0); $available = $requirement['candidates']; $valid_selection = $selected && in_array($selected, $available, true); if (!$valid_selection && $selected) unset($this->resources[$option_id]); if ($requirement['mode'] === ResourceSelectionModes::AUTO) { unset($this->resources[$option_id]); if (count($available)) $automatic_option_ids[] = $option_id; continue; } if ($valid_selection) $fixed_assignments[$option_id] = $selected; } usort($automatic_option_ids, function(int $left, int $right) use ($requirements): int { return count($requirements[$left]['candidates']) <=> count($requirements[$right]['candidates']); }); $assignments = $this->resolveAutomaticResourceAssignments( $fixed_assignments, $automatic_option_ids, $requirements, $engine ); foreach ($automatic_option_ids as $option_id) { if (isset($assignments[$option_id])) $this->resources[$option_id] = $assignments[$option_id]; else unset($this->resources[$option_id]); } } private function requiredResourceCapacity(OfferingOption $option, int $quantity): int { return $option->usesResourceCapacity() ? max(1, $quantity) : 1; } /** * @param array $selected_options * @return array */ private function resourceAssignmentRequirements(array $selected_options, AvailabilityEngine $engine): array { $requirements = []; foreach ($selected_options as $option_id => $quantity) { $option = $this->resourceOption($option_id); if (!$option || !$option->requiresResource()) continue; $required = $this->requiredResourceCapacity($option, $quantity); $requirements[$option_id] = [ 'option' => $option, 'quantity' => $quantity, 'required' => $required, 'mode' => $option->resourceSelectionMode(), 'candidates' => $this->availableResourceIds($option, $required, $engine), ]; } return $requirements; } /** * @param array $assignments * @param int[] $automatic_option_ids * @param array $requirements * @return array */ private function resolveAutomaticResourceAssignments( array $assignments, array $automatic_option_ids, array $requirements, AvailabilityEngine $engine ): array { if (!count($automatic_option_ids)) { return $this->resourceAssignmentFits($assignments, $requirements, $engine) ? $assignments : []; } $option_id = array_shift($automatic_option_ids); if (!isset($requirements[$option_id])) return []; foreach ($requirements[$option_id]['candidates'] as $resource_id) { $next = $assignments; $next[$option_id] = $resource_id; if (!$this->resourceAssignmentFits($next, $requirements, $engine)) continue; $resolved = $this->resolveAutomaticResourceAssignments($next, $automatic_option_ids, $requirements, $engine); if (isset($resolved[$option_id])) return $resolved; } return []; } /** * @param array $assignments * @param array $requirements */ private function resourceAssignmentFits(array $assignments, array $requirements, AvailabilityEngine $engine): bool { $capacity = $this->resourceSessionCapacityRequirements($requirements, $assignments); foreach ($assignments as $option_id => $resource_id) { if (!isset($requirements[$option_id])) continue; $option = $requirements[$option_id]['option']; $sessions = $this->sessionsForOption(absint($option_id)); if (!count($sessions)) continue; foreach ($sessions as $session) { $required_capacity = absint($capacity[$resource_id][$session] ?? 1); $available = $engine->availableResourceIdsForSessions( $this->getOffering(), $option, [(string) $session], $required_capacity, $this->availabilityExcludedBookingId() ); if (!in_array($resource_id, $available, true)) return false; } } return true; } /** * @param array> $requirements * @param array|null $assignments * @return array> */ private function resourceSessionCapacityRequirements(array $requirements, ?array $assignments = null): array { $capacity = []; $intervals = $this->resourceRequirementIntervals($requirements, $assignments); foreach ($intervals as $interval) { $resource_id = absint($interval['resource_id'] ?? 0); $session = (string) ($interval['session'] ?? ''); if (!$resource_id || $session === '') continue; $required_capacity = 0; foreach ($intervals as $candidate) { if (absint($candidate['resource_id'] ?? 0) !== $resource_id) continue; if (!$this->resourceIntervalsOverlap($interval, $candidate)) continue; $required_capacity += absint($candidate['required'] ?? 0); } if (!isset($capacity[$resource_id])) $capacity[$resource_id] = []; $capacity[$resource_id][$session] = max($capacity[$resource_id][$session] ?? 0, $required_capacity); } return $capacity; } /** * @param array> $requirements * @param array|null $assignments * @return array */ private function resourceRequirementIntervals(array $requirements, ?array $assignments = null): array { $intervals = []; foreach ($requirements as $option_id => $requirement) { $option = $requirement['option'] ?? null; if (!$option instanceof OfferingOption) continue; $resource_id = $assignments !== null ? absint($assignments[$option_id] ?? 0) : absint($requirement['selected'] ?? 0); if ($resource_id <= 0) continue; $required_capacity = absint($requirement['required'] ?? 0); if ($required_capacity <= 0) { $required_capacity = $this->requiredResourceCapacity( $option, absint($requirement['quantity'] ?? 0) ); } foreach ($this->sessionsForOption(absint($option_id)) as $session) { try { [$start, $end] = $this->resourceInterval($option, (string) $session); } catch (Exception $e) { continue; } if ($end <= $start) continue; $intervals[] = [ 'resource_id' => $resource_id, 'option' => $option, 'session' => (string) $session, 'required' => $required_capacity, 'start' => $start, 'end' => $end, ]; } } return $intervals; } /** * @return array{0: DateTimeImmutable, 1: DateTimeImmutable} * @throws Exception */ private function resourceInterval(OfferingOption $option, string $session): array { $UTC = new DateTimeZone('UTC'); $start = new DateTimeImmutable($session, $UTC); $end = $this->resourceSessionEnd($session, $start); if ($option->durationMinutes() > 0) { $end = $start->add(new DateInterval('PT' . $option->durationMinutes() . 'M')); } if ($option->bufferBeforeMinutes() > 0) { $start = $start->sub(new DateInterval('PT' . $option->bufferBeforeMinutes() . 'M')); } if ($option->bufferAfterMinutes() > 0) { $end = $end->add(new DateInterval('PT' . $option->bufferAfterMinutes() . 'M')); } return [$start, $end]; } /** * @throws Exception */ private function resourceSessionEnd(string $session, DateTimeImmutable $start): DateTimeImmutable { $UTC = new DateTimeZone('UTC'); try { $session_object = Session::getByTimestamp($session, $this->getOffering()); if ($session_object) { return new DateTimeImmutable($session_object->endDateTime(DateTimeFormat::tech(), $UTC), $UTC); } } catch (Exception $e) { } return $start->add($this->getOffering()->getInterval()); } /** * @param array{start: DateTimeImmutable, end: DateTimeImmutable} $left * @param array{start: DateTimeImmutable, end: DateTimeImmutable} $right */ private function resourceIntervalsOverlap(array $left, array $right): bool { return $left['start'] < $right['end'] && $left['end'] > $right['start']; } /** * @param int[] $resource_ids * @return array */ private function resourceChoices(array $resource_ids): array { $choices = []; $repository = new ResourcesRepository(); foreach ($resource_ids as $resource_id) { $resource = $repository->resource(absint($resource_id)); if (!$resource instanceof Resource) continue; $choices[$resource->id()] = $resource->name(); } return $choices; } public function paymentDescriptor(): string { // Offering Title $title = mb_substr($this->getOffering()->get()->post_title, 0, 60); return apply_filters('DayTidy/F__DayTidy/Booking/Draft__paymentDescriptor/description', sprintf( // translators: Placeholder values. esc_html__('Ref ID: %1$s, Offering: %2$s', 'daytidy'), $this->getId(), $title )); } /** * @param mixed[] $registration * @return self */ public function setRegistration(array $registration): self { $this->registration = $registration; return $this; } /** * @param string $coupon * @return self */ public function setCoupon(string $coupon): self { $this->coupon = sanitize_text_field($coupon); return $this; } public function getCoupon(): string { return $this->coupon; } /** * @return mixed[] */ public function getRegistration(): array { return $this->registration; } public function save(): void { update_option($this->storageKey($this->getId()), (string) $this, false); } private function storageKey(string $id): string { return self::STORAGE_PREFIX . hash('sha256', $id); } /** * @param int $skippable * @return self */ public function setSkippable(int $skippable): self { $this->skippable = $skippable; return $this; } public function getSkippable(): int { return $this->skippable; } /** * @param stdClass|null $request * @return string */ public function getStepToPerform(?stdClass $request): string { // All Steps $steps = $this->steps(); // Requested Step $requested = $request && isset($request->step) && $request->step ? $request->step : $steps[0]; // Go Back Request Received if ($request && isset($request->prev) && $request->prev == '1') { $this->setSkippable(0); return $this->getPreviousStep($requested); } // Next Step $next = ''; // Navigate Steps foreach ($steps as $step) { // Skip the Step if possible if ($this->canPassStep($step, $requested)) continue; $next = $step; break; } return $next; } /** * @param string $current * @return string */ public function getPreviousStep(string $current): string { $previous = ''; $steps = $this->steps(); foreach ($steps as $step) { if ($step === $current) break; $previous = $step; } return $previous; } /** * @param string $step * @param string $requested * @return bool */ public function canPassStep(string $step, string $requested): bool { return ($this->getSkippable() && $this->skippable($step)) || ( !$this->getSkippable() && $this->isBeforeStep($step, $requested) && $this->skippable($step) ); } /** * @param string $current * @param string $requested * @return bool */ public function isBeforeStep(string $current, string $requested): bool { $steps = $this->steps(); return array_search($current, $steps) <= array_search($requested, $steps); } public function progress(bool $finished = false): string { $steps = $this->steps(); $progress_bar = ''; $total = count($steps); $current = $finished ? '' : $this->getStepToPerform(Request::get()); $passed = true; $s = 1; foreach ($steps as $step) { $is_current = $current === $step; $is_complete = $passed && !$is_current; $state = $is_current ? 'current' : ($is_complete ? 'complete' : 'pending'); $state_label = $this->progressStateLabel($state); $label = BookingSteps::label($step, $this->offering); $classes = [ 'daytidy-booking-progress-bar-step', 'daytidy-booking-progress-bar-step-' . sanitize_html_class($step), 'daytidy-booking-progress-bar-step-' . sanitize_html_class($state), ]; if ($passed) $classes[] = 'daytidy-booking-progress-bar-active'; $progress_bar .= sprintf( '
  • %7$s
  • ', esc_attr(implode(' ', $classes)), esc_attr($step), esc_attr($state), $is_current ? ' aria-current="step"' : '', esc_attr(sprintf( /* translators: 1: current step number, 2: total step count, 3: step label, 4: step state. */ __('Step %1$d of %2$d: %3$s, %4$s', 'daytidy'), $s, $total, wp_strip_all_tags($label), $state_label )), $s, $label ); if ($passed) $passed = $current !== $step; $s++; } return sprintf( '
      %2$s
    ', esc_attr__('Booking progress', 'daytidy'), $progress_bar ); } private function progressStateLabel(string $state): string { if ($state === 'complete') return esc_html__('completed', 'daytidy'); if ($state === 'current') return esc_html__('current', 'daytidy'); return esc_html__('upcoming', 'daytidy'); } }