PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
fluent-support / app / Services / Integrations / FluentBooking / BookingAvailabilityHelper.php

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

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