|null */ private ?array $option_availability = null; private ?AvailabilityEngine $availability_engine = null; /** @var array */ private array $schedule_availability = []; /** * @param DraftBooking $booking * @param GeneralSettings $settings * @param stdClass|null $request */ public function __construct( DraftBooking $booking, GeneralSettings $settings, ?stdClass $request = null ) { $this->booking = $booking; $this->settings = $settings; $this->request = $request; $this->offering = $this->booking->getOffering(); // Plugin Profile self::$profile = Profile::getInstance(); } /** * @param mixed[] $options * @return string */ public function sessionCheckboxes(array $options = []): string { // Limit $limit = absint($options['limit'] ?? 20); if ($limit <= 0) $limit = 20; // Sessions $sessions = $this->availableSessions($limit); // UTC $UTC = new DateTimeZone('UTC'); return self::view()->render('booking.module.fields.session-checkboxes', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'sessions' => $sessions, 'UTC' => $UTC, 'availability' => $this->sessionAvailability($sessions, $UTC), ]); } /** * @param mixed[] $options * @return string */ public function optionSessionCheckboxes(array $options = []): string { // Limit $limit = absint($options['limit'] ?? 20); if ($limit <= 0) $limit = 20; // UTC $UTC = new DateTimeZone('UTC'); $groups = []; foreach ($this->selectedOptionsForSessionFilter() as $option_id => $quantity) { $option = $this->offering->getOptions()->get($option_id); if (!$option) continue; $sessions = $this->availableSessionsForSelectedOptions([$option_id => $quantity], $limit); $groups[$option_id] = [ 'option' => $option, 'quantity' => $quantity, 'sessions' => $sessions, 'availability' => $this->sessionAvailabilityForSelectedOptions( $sessions, $UTC, [$option_id => $quantity] ), ]; } return self::view()->render('booking.module.fields.option-session-checkboxes', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'groups' => $groups, 'UTC' => $UTC, ]); } /** * @param mixed[] $options * @return string */ public function sessionDropdown(array $options = []): string { // Limit $limit = absint($options['limit'] ?? 20); if ($limit <= 0) $limit = 20; // Sessions $sessions = $this->availableSessions($limit); // UTC $UTC = new DateTimeZone('UTC'); return self::view()->render('booking.module.fields.session-dropdown', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'sessions' => $sessions, 'UTC' => $UTC, 'availability' => $this->sessionAvailability($sessions, $UTC), ]); } /** * @param mixed[] $options * @return string */ public function dateDropdown(array $options = []): string { // Limit $limit = $options['limit'] ?? 20; // Sessions $sessions = $this->offering->getAvailableDates($limit); return self::view()->render('booking.module.fields.date-dropdown', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'sessions' => $sessions, ]); } /** * Render the shared availability-aware schedule picker. * * @param mixed[] $options */ public function schedulePicker(array $options = []): string { $booking_options = Config::module('booking'); $selected = isset($options['selected']) && is_array($options['selected']) ? $options['selected'] : $this->booking->getAllSessions(); $options = array_merge([ 'offering' => $this->offering, 'selected' => $selected, 'multiple' => BookingMethods::allowsMultipleSessions($this->booking->getBookingMethod()), 'input_name' => 'daytidy[sessions][]', 'month' => $this->request ? (string) ($this->request->schedule_month ?? '') : '', 'unavailable_dates' => $booking_options['schedule_picker_unavailable_dates'] ?? 'disabled', 'action' => 'daytidy-schedule-picker-month', 'compact_time_labels' => true, 'title_context' => 'fe', ], $options); $session_options = isset($options['session_options']) && is_array($options['session_options']) ? $options['session_options'] : []; $available_only = !array_key_exists('available_only', $options) || (bool) $options['available_only']; return (new SchedulePickerRenderer($this->scheduleAvailability($session_options, $available_only)))->render($options); } private function availabilityEngine(): AvailabilityEngine { if ($this->availability_engine === null) $this->availability_engine = AvailabilityEngine::snapshot(); return $this->availability_engine; } /** @param array $options */ private function scheduleAvailability(array $options, bool $available_only = true): ScheduleAvailability { ksort($options); $key = serialize([$options, $available_only]); if (!isset($this->schedule_availability[$key])) { $this->schedule_availability[$key] = new ScheduleAvailability( $this->offering, $options, $available_only, $this->availabilityEngine() ); } return $this->schedule_availability[$key]; } /** * @return Session[] */ public function availableSessions(int $limit = 20): array { $selected_options = $this->selectedOptionsForSessionFilter(); if (!count($selected_options)) return $this->offering->getAvailableSessions($limit); return $this->availableSessionsForSelectedOptions($selected_options, $limit); } public function selectedOptionsHaveAvailableSessions(): bool { $selected_options = $this->selectedOptionsForSessionFilter(); if (!count($selected_options)) return false; foreach ($selected_options as $option_id => $quantity) { if (!$this->scheduleAvailability([$option_id => $quantity])->first()) return false; } return true; } /** * @param array $selected_options * @return Session[] */ private function availableSessionsForSelectedOptions(array $selected_options, int $limit): array { $matching_sessions = []; $fetch_limit = max($limit, self::OPTION_SESSION_LOOKAHEAD); $scanned_sessions = 0; $engine = $this->availabilityEngine(); do { $sessions = $this->offering->getAvailableSessions($fetch_limit); $session_count = count($sessions); $engine->primeSessions($this->offering, $sessions); foreach (array_slice($sessions, $scanned_sessions) as $session) { if (!$this->sessionSupportsSelectedOptions($session, $selected_options, $engine)) continue; $matching_sessions[] = $session; if (count($matching_sessions) >= $limit) return $matching_sessions; } if ($session_count < $fetch_limit || $session_count <= $scanned_sessions) break; $scanned_sessions = $session_count; $fetch_limit += self::OPTION_SESSION_LOOKAHEAD; } while (true); return $matching_sessions; } /** * @return array */ private function selectedOptionsForSessionFilter(): array { if ($this->booking->getBookingMethod() !== BookingMethods::OPTION_SESSION) return []; $selected = []; foreach ($this->booking->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; } /** * @param array $selected_options */ private function sessionSupportsSelectedOptions( Session $session, array $selected_options, AvailabilityEngine $engine ): bool { $datetime = $session->startDateTime(DateTimeFormat::tech(), new DateTimeZone('UTC')); foreach ($selected_options as $option_id => $quantity) { $option = $this->offering ->getOptions() ->setSessions([$datetime]) ->get($option_id); if (!$option) return false; $option->setAvailabilityEngine($engine); if ($option->isUnavailable($quantity)) return false; } return true; } /** * @param Session[] $sessions * @return array> */ private function sessionAvailability(array $sessions, DateTimeZone $UTC): array { if ($this->booking->getBookingMethod() !== BookingMethods::OPTION_SESSION) return []; $selected_options = $this->selectedOptionsForSessionFilter(); if (!count($selected_options)) return []; return $this->sessionAvailabilityForSelectedOptions($sessions, $UTC, $selected_options); } /** * @param Session[] $sessions * @param array $selected_options * @return array> */ private function sessionAvailabilityForSelectedOptions( array $sessions, DateTimeZone $UTC, array $selected_options ): array { $availability = []; $engine = $this->availabilityEngine(); $engine->primeSessions($this->offering, $sessions); foreach ($sessions as $session) { $datetime = $session->startDateTime(DateTimeFormat::tech(), $UTC); foreach ($selected_options as $option_id => $quantity) { $option = $this->offering ->getOptions() ->setSessions([$datetime]) ->get($option_id); if (!$option) continue; $option->setAvailabilityEngine($engine); if ($option->isUnavailable($quantity)) continue; $availability[$datetime][$option_id] = [ 'title' => $option->title(), 'remaining' => $option->remaining(), ]; } } return $availability; } /** * @param mixed[] $options * @return string * @throws Exception */ public function timeDropdown(array $options = []): string { // Date $date = $options['date'] ?? new DateTime('now', new DateTimeZone('UTC')); // Times $sessions = $this->offering->getDateSessions($date); return self::view()->render('booking.module.fields.time-dropdown', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'sessions' => $sessions, ]); } /** * @param mixed[] $options * @return string */ public function options(array $options = []): string { $availability = []; if ($this->booking->getBookingMethod() === BookingMethods::OPTION_SESSION) { $options = $this->offering->getOptions()->all(); $availability = $this->optionAvailability(); } else { // Options $options = $this->offering->getOptions() ->setSessions($this->booking->getAllSessions()) ->all(); } // Price Renderer $price = (new Price())->byOffering($this->offering); return self::view()->render('booking.module.fields.options', [ 'booking' => $this->booking, 'settings' => $this->settings, 'price' => $price, 'request' => $this->request, 'options' => $options, 'labels' => $this->offering->getTypeLabels(), 'availability' => $availability, ]); } public function hasAvailableOptions(): bool { if ($this->booking->getBookingMethod() !== BookingMethods::OPTION_SESSION) { return (bool) count( $this->offering ->getOptions() ->setSessions($this->booking->getAllSessions()) ->available() ); } foreach ($this->optionAvailability() as $option) { if ($option['available']) return true; } return false; } /** * @return array */ public function optionAvailability(): array { if ($this->option_availability !== null) return $this->option_availability; $this->option_availability = []; if ($this->booking->getBookingMethod() !== BookingMethods::OPTION_SESSION) return $this->option_availability; // Probe a small batch first. Once the maximum possible capacity is found, // later sessions cannot change the service selector's availability. $sessions = $this->offering->getAvailableSessions(20); $full_sessions = null; $UTC = new DateTimeZone('UTC'); $engine = $this->availabilityEngine(); $engine->primeSessions($this->offering, $sessions); foreach ($this->offering->getOptions()->all() as $option) { $maximum = $option->capacity(); if ($option->requiresResource() && $option->usesResourceCapacity()) { $resource_maximum = $engine->capacityUpperBound($this->offering, $option); $maximum = $option->capacityBehavior() === OptionCapacityBehaviors::RESOURCE_CAPACITY ? $resource_maximum : min($maximum, $resource_maximum); } $availability = $this->optionSessionAvailability($option, $sessions, $UTC, $engine, $maximum); if (!$availability['available'] || $maximum <= 0 || $availability['remaining'] < $maximum) { // Preserve the full lookahead for sold-out, closed-window, and // variable-capacity cases that the small probe cannot settle. if ($full_sessions === null) $full_sessions = $this->offering->getAvailableSessions(self::OPTION_SESSION_LOOKAHEAD); $engine->primeSessions($this->offering, $full_sessions); $availability = $this->optionSessionAvailability($option, $full_sessions, $UTC, $engine, $maximum); } $this->option_availability[$option->id()] = $availability; } return $this->option_availability; } /** * @param Session[] $sessions * @return array{available: bool, remaining: int, message: string} */ private function optionSessionAvailability( OfferingOption $option, array $sessions, DateTimeZone $UTC, AvailabilityEngine $engine, int $maximum = 0 ): array { $remaining = 0; $minimum = max(1, $option->minToBuy()); $option_available = $option->isAvailable($minimum); $exhausted_lookahead = count($sessions) < self::OPTION_SESSION_LOOKAHEAD; foreach ($sessions as $session) { $datetime = $session->startDateTime(DateTimeFormat::tech(), $UTC); $session_option = $this->offering ->getOptions() ->setSessions([$datetime]) ->get($option->id()); if (!$session_option) continue; $session_option->setAvailabilityEngine($engine); if (!$session_option->isAvailable($minimum)) continue; $remaining = max($remaining, $session_option->remaining()); if ($maximum > 0 && $remaining >= $maximum) break; } $available = $remaining > 0 || (!$exhausted_lookahead && $option_available); $message = ''; if (!$available) { $message = $option_available ? $this->unavailableOptionSessionMessage($option) : $option->getUnavailableMessage($minimum); } return [ 'available' => $available, 'remaining' => $remaining > 0 ? $remaining : $option->remaining(), 'message' => $message, ]; } private function unavailableOptionSessionMessage(OfferingOption $option): string { $option_label = strtolower(wp_strip_all_tags($this->offering->getTypeLabel('option'))); $time_label = strtolower(wp_strip_all_tags($this->offering->getTypeLabel('time'))); return sprintf( // translators: Placeholder values. esc_html__('No available %1$s was found for %2$s. Please choose another %3$s.', 'daytidy'), esc_html($time_label), '' . esc_html($option->title()) . '', esc_html($option_label) ); } /** * @param mixed[] $errors * @param mixed[] $options * @return string */ public function bookingForm(array $errors = [], array $options = []): string { // Fields of Booking Form [$fields] = $this->offering->getBookingFields(); return self::view()->render('booking.module.fields.booking-form', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'fields' => $fields['booking'] ?? [], 'errors' => $errors, 'options' => $options, ]); } /** * @param string[] $errors * @return string */ public function resources(array $errors = []): string { $requirements = $this->booking->getResourceRequirements(); if (!count($requirements) && !count($errors)) return ''; return self::view()->render('booking.module.fields.resources', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'requirements' => $requirements, 'errors' => $errors, ]); } /** * @param mixed[] $errors * @param mixed[] $options * @return string */ public function participantsForm(array $errors = [], array $options = []): string { // Fields of Booking Form [$fields] = $this->offering->getBookingFields(); // Price Renderer $price = (new Price())->byOffering($this->offering); return self::view()->render('booking.module.fields.participants-form', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'price' => $price, 'fields' => $fields['participant'] ?? [], 'errors' => $errors, 'options' => $options, ]); } /** * @param mixed[] $options * @return string */ public function coupon(array $options = []): string { return self::view()->render('booking.module.fields.coupon', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'options' => $options, ]); } /** * @param mixed[] $errors * @param mixed[] $options * @return string */ public function registrationForm(array $errors = [], array $options = []): string { // Logged-in User if (get_current_user_id()) return ''; // Module Config $config = $this->settings->module('booking'); // Registration is Disabled if (!isset($config['registration']) || !$config['registration']) return ''; // Username & Password Method $method = isset($config['userpass']) && trim($config['userpass']) ? $config['userpass'] : 'auto'; // Auto Populate if ($method === 'auto') return ''; $fields = [ 'username' => [ 'type' => 'text', 'label' => esc_html__('Username', 'daytidy'), 'required' => 1, ], 'password' => [ 'type' => 'password', 'label' => esc_html__('Password', 'daytidy'), 'required' => 1, ], ]; return self::view()->render('booking.module.fields.registration-form', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'fields' => $fields, 'errors' => $errors, 'options' => $options, ]); } /** * @param string[] $errors * @return string */ public function errors(array $errors = []): string { return self::view()->render('booking.module.fields.errors', [ 'errors' => $errors, 'request' => $this->request, ]); } /** * @param mixed[] $options * @return string */ public function submit(array $options = []): string { // Options $title = $options['title'] ?? esc_html__('Next', 'daytidy'); $prev_title = $options['prev_title'] ?? esc_html__('Prev', 'daytidy'); $step = $options['step'] ?? ''; $next = $options['next'] ?? true; return self::view()->render('booking.module.fields.submit', [ 'booking' => $this->booking, 'settings' => $this->settings, 'request' => $this->request, 'title' => $title, 'prev_title' => $prev_title, 'step' => $step, 'next' => $next, ]); } }