PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.2.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.2.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
fluent-support / app / Services / Integrations / FluentBooking / BookingAvailabilityHelper.php

BookingAvailabilityHelper.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.2.0, at app/Services/Integrations/FluentBooking/BookingAvailabilityHelper.php

577 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services\Integrations\FluentBooking;
4
5 use FluentSupport\App\Models\Ticket;
6 use FluentSupport\Framework\Support\Arr;
7
8 class BookingAvailabilityHelper
9 {
10 /**
11 * Validates and returns an IANA timezone string, falling back to $fallback then to the WordPress site timezone.
12 *
13 * Delegates validation to DateTimeHelper::getValidatedTimeZone() which uses a static per-request cache and
14 * applies the fluent_booking/fallback_timezone filter, avoiding repeated scans of DateTimeZone::listIdentifiers().
15 *
16 * @param string $timezone
17 * @param string $fallback
18 * @return string
19 */
20 public function sanitizeTimezone($timezone, $fallback = '')
21 {
22 $timezone = sanitize_text_field($timezone);
23 $fallback = sanitize_text_field($fallback);
24
25 if ($timezone) {
26 $validated = \FluentBooking\App\Services\DateTimeHelper::getValidatedTimeZone($timezone);
27 if ($validated === $timezone) {
28 return $timezone;
29 }
30 }
31
32 if ($fallback) {
33 $validated = \FluentBooking\App\Services\DateTimeHelper::getValidatedTimeZone($fallback);
34 if ($validated === $fallback) {
35 return $fallback;
36 }
37 }
38
39 return \FluentBooking\App\Services\DateTimeHelper::getTimeZone();
40 }
41
42 /**
43 * Builds the start/end date window for an availability query.
44 *
45 * Handles named ranges (next_3_days, this_week, next_week, next_14_days), specific date lists,
46 * and calendar-month requests.
47 *
48 * @param string $range
49 * @param string $timezone
50 * @param array $selectedDates
51 * @param string $calendarMonth
52 * @return array
53 */
54 public function getAvailabilityRange($range, $timezone, $selectedDates = [], $calendarMonth = '')
55 {
56 $range = sanitize_key($range);
57 $timezoneObject = new \DateTimeZone($timezone);
58 $selectedDates = $this->sanitizeSpecificDates($selectedDates, $timezoneObject);
59 $calendarMonth = $this->sanitizeCalendarMonth($calendarMonth, $timezoneObject);
60
61 if ($range === 'specific_dates' && $selectedDates) {
62 $start = new \DateTime($selectedDates[0] . ' 00:00:00', $timezoneObject);
63 $end = new \DateTime(end($selectedDates) . ' 23:59:59', $timezoneObject);
64
65 return [
66 'key' => $range,
67 'start' => $start->format('Y-m-d H:i:s'),
68 'end' => $end->format('Y-m-d H:i:s'),
69 'days' => count($selectedDates),
70 'selected_dates' => $selectedDates
71 ];
72 }
73
74 if ($range === 'specific_dates' && $calendarMonth) {
75 $start = new \DateTime($calendarMonth . '-01 00:00:00', $timezoneObject);
76 $end = clone $start;
77 $end->modify('last day of this month')->setTime(23, 59, 59);
78
79 return [
80 'key' => $range,
81 'start' => $start->format('Y-m-d H:i:s'),
82 'end' => $end->format('Y-m-d H:i:s'),
83 'days' => (int) $end->format('j')
84 ];
85 }
86
87 $start = new \DateTime('today', $timezoneObject);
88
89 $rangeDays = [
90 'next_3_days' => 3,
91 'this_week' => 7,
92 'next_week' => 7,
93 'next_14_days' => 14
94 ];
95
96 if ($range === 'next_week') {
97 $start->modify('+7 days');
98 } elseif (!isset($rangeDays[$range])) {
99 $range = 'next_3_days';
100 }
101
102 $end = clone $start;
103 $end->modify('+' . ($rangeDays[$range] - 1) . ' days')->setTime(23, 59, 59);
104
105 return [
106 'key' => $range,
107 'start' => $start->format('Y-m-d H:i:s'),
108 'end' => $end->format('Y-m-d H:i:s'),
109 'days' => $rangeDays[$range]
110 ];
111 }
112
113 /**
114 * Initialises the FluentBooking time-slot service, fetches available spots, and returns formatted day/slot arrays.
115 *
116 * Applies the fluent_booking/available_slots_for_view filter before formatting.
117 * Throws on service init failure so callers can handle it at the appropriate level.
118 *
119 * @param object $event
120 * @param array $rangeData
121 * @param string $timezone
122 * @param Ticket|null $ticket
123 * @param int|null $duration
124 * @throws \Exception
125 * @return array
126 */
127 public function fetchFormattedDays($event, $rangeData, $timezone, Ticket $ticket = null, $duration = null)
128 {
129 $timeSlotService = \FluentBooking\App\Hooks\Handlers\TimeSlotServiceHandler::initService($event->calendar, $event);
130
131 if (is_wp_error($timeSlotService)) {
132 throw new \Exception(esc_html($timeSlotService->get_error_message()));
133 }
134
135 $availableSpots = $timeSlotService->getAvailableSpots($rangeData['start'], $timezone, (int) $duration);
136
137 if (is_wp_error($availableSpots)) {
138 $availableSpots = [];
139 }
140
141 $availableSpots = apply_filters(
142 'fluent_booking/available_slots_for_view',
143 array_filter((array) $availableSpots),
144 $event,
145 $event->calendar,
146 $timezone,
147 (int) $duration
148 );
149
150 return $this->formatDays($availableSpots, $rangeData, $timezone, $event, $ticket, $duration);
151 }
152
153 /**
154 * Validates user-submitted slot start times against live FluentBooking availability.
155 *
156 * Returns only slots that are still bookable.
157 *
158 * @param array $selectedSlots
159 * @param object $event
160 * @param Ticket $ticket
161 * @param int|null $duration
162 * @return array
163 */
164 public function sanitizeSelectedSlots($selectedSlots, $event, Ticket $ticket, $duration = null)
165 {
166 if (!is_array($selectedSlots)) {
167 return [];
168 }
169
170 $timezone = $this->sanitizeTimezone('', $event->calendar->author_timezone);
171 $timezoneObject = new \DateTimeZone($timezone);
172 $selectedStarts = [];
173 $selectedDates = [];
174
175 foreach (array_slice($selectedSlots, 0, 10) as $slot) {
176 if (!is_array($slot)) {
177 continue;
178 }
179
180 $startValue = sanitize_text_field(Arr::get($slot, 'start'));
181
182 if (!$startValue) {
183 continue;
184 }
185
186 try {
187 $startDate = new \DateTime($startValue, $timezoneObject);
188 $selectedStarts[] = $startDate->format('Y-m-d H:i:s');
189 $selectedDates[] = $startDate->format('Y-m-d');
190 } catch (\Exception $exception) {
191 continue;
192 }
193 }
194
195 if (!$selectedStarts) {
196 return [];
197 }
198
199 $selectedStarts = array_values(array_unique($selectedStarts));
200 $rangeData = $this->getAvailabilityRange('specific_dates', $timezone, $selectedDates);
201
202 if (empty(Arr::get($rangeData, 'selected_dates'))) {
203 return [];
204 }
205
206 try {
207 $formattedDays = $this->fetchFormattedDays($event, $rangeData, $timezone, $ticket, $duration);
208 } catch (\Exception $e) {
209 return [];
210 }
211
212 $availableSlots = [];
213
214 foreach ($formattedDays as $day) {
215 foreach ((array) Arr::get($day, 'slots', []) as $slot) {
216 $availableSlots[$slot['start']] = $slot;
217 }
218 }
219
220 $slots = [];
221
222 foreach ($selectedStarts as $selectedStart) {
223 if (empty($availableSlots[$selectedStart])) {
224 continue;
225 }
226
227 $availableSlot = $availableSlots[$selectedStart];
228
229 $slots[] = [
230 'display_text' => $availableSlot['display_text'],
231 'time_label' => $availableSlot['time_label'],
232 'start' => $availableSlot['start'],
233 'booking_url' => $availableSlot['booking_url']
234 ];
235 }
236
237 return $slots;
238 }
239
240 /**
241 * Appends the link token to the booking_url of each slot so individual slot links are traceable back to this send.
242 *
243 * @param array $selectedSlots
244 * @param string $linkToken
245 * @return array
246 */
247 public function addTokenToSlots($selectedSlots, $linkToken)
248 {
249 if (!$selectedSlots || !is_array($selectedSlots)) {
250 return [];
251 }
252
253 return array_map(function ($slot) use ($linkToken) {
254 if (!empty($slot['booking_url'])) {
255 $slot['booking_url'] = FluentBookingService::addBookingLinkToken($slot['booking_url'], $linkToken);
256 }
257
258 return $slot;
259 }, $selectedSlots);
260 }
261
262 /**
263 * Builds the HTML block of grouped time-slot links for insertion into an agent reply.
264 *
265 * @param array $selectedSlots
266 * @param object $event
267 * @param string $timezone
268 * @return string
269 */
270 public function formatSlotsHtml($selectedSlots, $event, $timezone = '')
271 {
272 if (!$selectedSlots) {
273 return '';
274 }
275
276 $timezone = sanitize_text_field($timezone) ?: $this->sanitizeTimezone('', Arr::get((array) $event->calendar, 'author_timezone', ''));
277 $timezoneObject = new \DateTimeZone($timezone);
278 $groupedSlots = [];
279
280 foreach ($selectedSlots as $slot) {
281 $startValue = $slot['start'] ?? '';
282 $slotUrl = $slot['booking_url'] ?? '';
283
284 if (!$startValue) {
285 continue;
286 }
287
288 $start = new \DateTime($startValue, $timezoneObject);
289 $dateKey = $start->format('Y-m-d');
290
291 $groupedSlots[$dateKey] = $groupedSlots[$dateKey] ?? [
292 'heading' => wp_date('l, F j', $start->getTimestamp(), $timezoneObject),
293 'slots' => []
294 ];
295
296 $groupedSlots[$dateKey]['slots'][] = [
297 'label' => $slot['time_label'],
298 'url' => esc_url($slotUrl)
299 ];
300 }
301
302 if (!$groupedSlots) {
303 return '';
304 }
305
306 $eventTitle = '';
307
308 if (!empty($event->title)) {
309 $eventTitle = sanitize_text_field($event->title);
310 } elseif (!empty($event->calendar_title)) {
311 $eventTitle = sanitize_text_field($event->calendar_title);
312 }
313
314 $eventDuration = $this->formatMeetingDuration((int) $event->getDuration());
315 $html = '<div class="fs_fluent_booking_suggested_times">';
316
317 if ($eventTitle) {
318 $html .= '<p class="fs_fluent_booking_suggested_times__title"><strong>' . esc_html($eventTitle) . '</strong></p>';
319 }
320
321 if ($eventDuration) {
322 $html .= '<p class="fs_fluent_booking_suggested_times__meta">' . esc_html($eventDuration) . '</p>';
323 }
324
325 if ($timezone) {
326 $html .= '<p class="fs_fluent_booking_suggested_times__meta">';
327 $html .= esc_html__('Time zone:', 'fluent-support') . ' ' . esc_html($timezone);
328 $html .= '</p>';
329 }
330
331 foreach ($groupedSlots as $group) {
332 $html .= '<div class="fs_fluent_booking_suggested_times__group">';
333 $html .= '<p class="fs_fluent_booking_suggested_times__heading"><strong>' . esc_html($group['heading']) . '</strong></p>';
334 $html .= '<div class="fs_fluent_booking_suggested_times__slots">';
335
336 foreach ($group['slots'] as $slot) {
337 $slotLabel = esc_html($slot['label']);
338 $slotUrl = !empty($slot['url']) ? esc_url($slot['url']) : '';
339
340 if ($slotUrl) {
341 $html .= '<a class="fs_fluent_booking_suggested_times__slot" href="' . $slotUrl . '" target="_blank" rel="noopener">' . $slotLabel . '</a>';
342 } else {
343 $html .= '<span class="fs_fluent_booking_suggested_times__slot">' . $slotLabel . '</span>';
344 }
345 }
346
347 $html .= '</div></div>';
348 }
349
350 $html .= '</div>';
351
352 return $html;
353 }
354
355 /**
356 * Builds the plain-text equivalent of the booking suggestion for clipboard copy.
357 *
358 * @param string $message
359 * @param array $selectedSlots
360 * @param string $bookingUrl
361 * @return string
362 */
363 public function formatSlotsPlainText($message, $selectedSlots, $bookingUrl)
364 {
365 $parts = [];
366
367 if ($message !== '') {
368 $parts[] = wp_strip_all_tags($message);
369 }
370
371 $slotLines = [];
372
373 foreach ($selectedSlots as $slot) {
374 $displayText = sanitize_text_field(Arr::get($slot, 'display_text'));
375 $slotUrl = esc_url_raw(Arr::get($slot, 'booking_url'));
376
377 if ($displayText && $slotUrl) {
378 $slotLines[] = "{$displayText}: {$slotUrl}";
379 }
380 }
381
382 if ($slotLines) {
383 $parts[] = implode("\n", $slotLines);
384 }
385
386 /* translators: followed by the booking URL. */
387 $parts[] = esc_html__('See all available times', 'fluent-support') . ': ' . esc_url_raw($bookingUrl);
388
389 return implode("\n\n", $parts);
390 }
391
392 /**
393 * Maps raw FluentBooking spot data into the day-grouped slot structure returned to the frontend.
394 *
395 * Filters results to the requested date range.
396 *
397 * @param array $availableSpots
398 * @param array $rangeData
399 * @param string $timezone
400 * @param object $event
401 * @param Ticket|null $ticket
402 * @param int|null $duration
403 * @return array
404 */
405 private function formatDays($availableSpots, $rangeData, $timezone, $event, Ticket $ticket = null, $duration = null)
406 {
407 if (!$availableSpots || !is_array($availableSpots)) {
408 return [];
409 }
410
411 $timezoneObject = new \DateTimeZone($timezone);
412 $rangeStart = new \DateTime($rangeData['start'], $timezoneObject);
413 $rangeEnd = new \DateTime($rangeData['end'], $timezoneObject);
414 $days = [];
415 $maxDays = (int) Arr::get($rangeData, 'days', 7);
416 $selectedDateMap = array_flip((array) Arr::get($rangeData, 'selected_dates', []));
417
418 foreach ($availableSpots as $date => $spots) {
419 if (!is_array($spots)) {
420 continue;
421 }
422
423 foreach ($spots as $spot) {
424 $startValue = sanitize_text_field(Arr::get($spot, 'start'));
425 $endValue = sanitize_text_field(Arr::get($spot, 'end'));
426
427 if (!$startValue || !$endValue) {
428 continue;
429 }
430
431 $start = new \DateTime($startValue, $timezoneObject);
432 $end = new \DateTime($endValue, $timezoneObject);
433
434 if ($start < $rangeStart || $start > $rangeEnd) {
435 continue;
436 }
437
438 $dateKey = $start->format('Y-m-d');
439
440 if ($selectedDateMap && !isset($selectedDateMap[$dateKey])) {
441 continue;
442 }
443
444 $days[$dateKey] = $days[$dateKey] ?? [
445 'date' => $dateKey,
446 'label' => wp_date('l, M j', $start->getTimestamp(), $timezoneObject),
447 'slots' => []
448 ];
449
450 $timeLabel = wp_date(get_option('time_format'), $start->getTimestamp(), $timezoneObject);
451 $endLabel = wp_date(get_option('time_format'), $end->getTimestamp(), $timezoneObject);
452
453 $days[$dateKey]['slots'][] = [
454 'id' => sanitize_key($dateKey . '_' . $start->format('His')),
455 'start' => $start->format('Y-m-d H:i:s'),
456 'booking_url' => $this->getSlotBookingUrl($event, $ticket, $start, $timezone, $duration),
457 'time_label' => $timeLabel,
458 'display_text' => sprintf(
459 /* translators: %1$s is date, %2$s is start time, %3$s is end time, %4$s is timezone. */
460 __('%1$s at %2$s - %3$s (%4$s)', 'fluent-support'),
461 wp_date('D, M j', $start->getTimestamp(), $timezoneObject),
462 $timeLabel,
463 $endLabel,
464 $timezone
465 )
466 ];
467 }
468 }
469
470 ksort($days);
471
472 return array_slice(array_values($days), 0, $maxDays);
473 }
474
475 /**
476 * Validates, deduplicates, and sorts an array of Y-m-d date strings, capping at 7 entries.
477 *
478 * @param array $selectedDates
479 * @param \DateTimeZone $timezoneObject
480 * @return array
481 */
482 private function sanitizeSpecificDates($selectedDates, \DateTimeZone $timezoneObject)
483 {
484 if (!is_array($selectedDates)) {
485 return [];
486 }
487
488 $dates = [];
489
490 foreach (array_slice($selectedDates, 0, 7) as $selectedDate) {
491 $selectedDate = sanitize_text_field($selectedDate);
492
493 if (!$selectedDate) {
494 continue;
495 }
496
497 $date = \DateTime::createFromFormat('Y-m-d', $selectedDate, $timezoneObject);
498
499 if (!$date || $date->format('Y-m-d') !== $selectedDate) {
500 continue;
501 }
502
503 $dates[] = $selectedDate;
504 }
505
506 $dates = array_values(array_unique($dates));
507 sort($dates);
508
509 return $dates;
510 }
511
512 /**
513 * Validates a Y-m month string against the given timezone, returning an empty string if invalid.
514 *
515 * @param string $calendarMonth
516 * @param \DateTimeZone $timezoneObject
517 * @return string
518 */
519 private function sanitizeCalendarMonth($calendarMonth, \DateTimeZone $timezoneObject)
520 {
521 $calendarMonth = sanitize_text_field($calendarMonth);
522
523 if (!$calendarMonth) {
524 return '';
525 }
526
527 $monthDate = \DateTime::createFromFormat('Y-m', $calendarMonth, $timezoneObject);
528
529 if (!$monthDate || $monthDate->format('Y-m') !== $calendarMonth) {
530 return '';
531 }
532
533 return $calendarMonth;
534 }
535
536 /**
537 * Builds a booking URL pre-selecting a specific date, time, duration, and timezone.
538 *
539 * @param object $event
540 * @param Ticket|null $ticket
541 * @param \DateTime $start
542 * @param string $timezone
543 * @param int|null $duration
544 * @return string
545 */
546 private function getSlotBookingUrl($event, ?Ticket $ticket, \DateTime $start, $timezone = '', $duration = null)
547 {
548 $queryArgs = [
549 'month' => $start->format('Y-m'),
550 'date' => $start->format('Y-m-d'),
551 'time' => $start->format('H:i:s'),
552 'duration' => (int) $duration,
553 'timezone' => sanitize_text_field($timezone)
554 ];
555
556 return esc_url_raw(add_query_arg(array_filter($queryArgs), FluentBookingService::getEventUrl($event, $ticket)));
557 }
558
559 /**
560 * Returns a localised "%d min" string, or an empty string for zero/negative durations.
561 *
562 * @param int $duration
563 * @return string
564 */
565 private function formatMeetingDuration($duration)
566 {
567 $duration = (int) $duration;
568
569 if ($duration <= 0) {
570 return '';
571 }
572
573 /* translators: %d is meeting duration in minutes. */
574 return sprintf(esc_html__('%d min', 'fluent-support'), $duration);
575 }
576 }
577