From 5d41660a9ae5f2579666ac14189bba8ba2d8f7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 29 Oct 2026 14:03:59 +0100 Subject: [PATCH] feat: availability calendar in the product detail sidebar is back TYPO3 10 showed a "Belegungskalender" in the sidebar of every product with a calendar hotel. It was dropped when the price calendar arrived, and the price calendar is limited to summer products, so winter products showed no availability at all. The sidebar calendar is shown for products with a calendar hotel that are not summer products. AjaxCalendar::availability reads the availability from MyE&P (contingents/availability, as the iCal feed does) instead of bpn-connect, and renders all months in one cacheable response; the month select only switches between them. Months with prices in the price config (summer, covered by the price calendar) are left out. If the price config is unavailable, all months are shown without caching. The plain availability-* classes are safelisted in Tailwind: they were only kept on TYPO3 10 because the ContingentViewHelper named them, and the class names are now built in the controller. Co-Authored-By: Claude Opus 5.5 --- .../Controller/AjaxCalendarController.php | 110 ++++++++++++++++++ packages/ep_products/ext_localconf.php | 2 +- .../js/controllers/calendar_controller.js | 64 +--------- .../Templates/AjaxCalendar/Availability.html | 62 ++++++++++ .../Private/Templates/Product/Detail.html | 23 ++++ tailwind.config.js | 3 +- 6 files changed, 204 insertions(+), 60 deletions(-) create mode 100644 packages/ep_theme/Resources/Private/Templates/AjaxCalendar/Availability.html diff --git a/packages/ep_products/Classes/Controller/AjaxCalendarController.php b/packages/ep_products/Classes/Controller/AjaxCalendarController.php index b1cb1bf..15da4e6 100644 --- a/packages/ep_products/Classes/Controller/AjaxCalendarController.php +++ b/packages/ep_products/Classes/Controller/AjaxCalendarController.php @@ -112,6 +112,97 @@ class AjaxCalendarController extends ActionController return $this->htmlResponse(); } + /** + * Availability calendar (Belegungskalender) of the product detail sidebar, for products + * without the price calendar, limited to the months without prices (winter). TYPO3 10 + * fetched it month by month from bpn-connect; MyE&P returns the whole snapshot at once, + * so every month is rendered and the month select only switches between them. + */ + public function availabilityAction(Hotel $hotel, int $months = 12): ResponseInterface + { + $hotelCode = trim($hotel->getCode() ?? ''); + $this->tagPageCacheForHotel($hotelCode); + + $period = DateUtility::getDateRange(null, null, $months)['period']; + $today = new \DateTimeImmutable('today'); + + $statusByDate = []; + $error = false; + + if ('' === $hotelCode) { + $error = true; + $this->disablePageCache(); + } else { + try { + foreach ($this->myEpClient->getContingentAvailability($hotelCode) as $item) { + if (!is_array($item) || !isset($item['date'])) { + continue; + } + $statusByDate[$item['date']] = $this->normalizeAvailabilityStatus((string)($item['status'] ?? 'OK')); + } + } catch (ApiException $e) { + $error = true; + $this->disablePageCache(); + } + } + + if ([] !== $statusByDate) { + $lastDataDate = new \DateTimeImmutable(max(array_keys($statusByDate))); + $dataEnd = $lastDataDate->modify('first day of next month')->setTime(0, 0, 0); + if ($dataEnd > $period->getEndDate()) { + $period = new Period($period->getStartDate(), $dataEnd); + } + } + + // Days without a row are available, as on TYPO3 10. A change of the occupancy level + // from the previous day is drawn as a diagonal transition (arrival/departure day). + $classByDate = []; + $previousStatus = $statusByDate[$period->getStartDate()->modify('-1 day')->format('Y-m-d')] ?? 'OK'; + for ($day = \DateTimeImmutable::createFromInterface($period->getStartDate()); $day < $period->getEndDate(); $day = $day->modify('+1 day')) { + $dateKey = $day->format('Y-m-d'); + $status = $statusByDate[$dateKey] ?? 'OK'; + if ($day < $today) { + $classByDate[$dateKey] = 'text-zinc-400'; + } elseif ($previousStatus !== $status) { + $classByDate[$dateKey] = 'text-white availability-' . $this->getAvailabilitySuffix($previousStatus) . '-to-' . $this->getAvailabilitySuffix($status); + } else { + $classByDate[$dateKey] = 'text-white availability-' . $this->getAvailabilitySuffix($status); + } + $previousStatus = $status; + } + + // Only summer months have prices in the price config, and those are covered by the + // price calendar, so they are left out here. If the price config is unavailable, all + // months are shown, but not cached. + $priceMonths = []; + if (!$error) { + try { + foreach ($this->myEpClient->getPriceConfigData($hotelCode) as $item) { + if (!is_array($item) || !isset($item['date'])) { + continue; + } + if (null !== ($item['pricePerNight'] ?? null) || null !== ($item['defaultPricePerNight'] ?? null)) { + $priceMonths[substr((string)$item['date'], 0, 7)] = true; + } + } + } catch (ApiException $e) { + $this->disablePageCache(); + } + } + + $calendarMonths = array_values(array_filter( + $this->buildCalendarMonths($period), + fn (array $month): bool => !isset($priceMonths[$month['date']->format('Y-m')]) + )); + + $this->view->assignMultiple([ + 'classByDate' => $classByDate, + 'calendarMonths' => $calendarMonths, + 'error' => $error, + ]); + return $this->htmlResponse(); + } + public function priceTableAction(Hotel $hotel, ?string $year = null): ResponseInterface { $year ??= new \DateTimeImmutable()->format('Y'); @@ -167,6 +258,25 @@ class AjaxCalendarController extends ActionController $this->request->getAttribute('frontend.cache.instruction')?->disableCache('Price calendar data could not be retrieved'); } + private function normalizeAvailabilityStatus(string $status): string + { + $status = strtoupper(trim($status)); + if ('ONREQUEST' === $status) { + return 'ON_REQUEST'; + } + + return in_array($status, ['OK', 'ON_REQUEST', 'BLOCKED'], true) ? $status : 'OK'; + } + + private function getAvailabilitySuffix(string $status): string + { + return match ($status) { + 'BLOCKED' => 'none', + 'ON_REQUEST' => 'partial', + default => 'full', + }; + } + private function buildTooltip(string $status, array $item): string { if ($status === 'blocked') { diff --git a/packages/ep_products/ext_localconf.php b/packages/ep_products/ext_localconf.php index dc9c961..10afaa5 100644 --- a/packages/ep_products/ext_localconf.php +++ b/packages/ep_products/ext_localconf.php @@ -267,7 +267,7 @@ $boot = function (): void { AjaxFilterPanelController::class => 'index', AjaxWatchlistController::class => 'list', AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable', - AjaxCalendarController::class => 'priceCalendar,priceTable', + AjaxCalendarController::class => 'priceCalendar,priceTable,availability', AjaxTeaserPopupController::class => 'concept', ], [ diff --git a/packages/ep_theme/Resources/Private/Assets/js/controllers/calendar_controller.js b/packages/ep_theme/Resources/Private/Assets/js/controllers/calendar_controller.js index 1e34b8e..6776285 100644 --- a/packages/ep_theme/Resources/Private/Assets/js/controllers/calendar_controller.js +++ b/packages/ep_theme/Resources/Private/Assets/js/controllers/calendar_controller.js @@ -1,68 +1,16 @@ import { Controller } from '@hotwired/stimulus' -import { useFetch } from '../mixins/use_fetch' export default class extends Controller { static targets = [ - 'container', 'field', - 'loadingIndicator', + 'month', ] - static values = { - loading: Boolean, - uri: String, - hotelUid: Number, - } - - initialize() { - useFetch(this) - } - - connect() { - let months = [] - let today = new Date() - let year = today.getFullYear() - let month = today.getMonth() - for (let i = 0; i < 20; i++) { - let selectableDate = new Date(year, month, 1) - months.push(selectableDate) - if (month < 12) { - month++ - } else { - month = 1 - year++ - } - this.fieldTarget.options[i] = new Option(selectableDate.toLocaleDateString('de-DE', {year: 'numeric', month: 'long'}), i.toString()) - } - this.selectableMonths = months - this.load(0) - } - - select(e) { - let selectedIndex = e.currentTarget.value - this.load(selectedIndex) - } - - load(selectedIndex) { - this.loadingValue = true - let selectedMonth = this.selectableMonths[selectedIndex] - let data = new URLSearchParams() - data.set('tx_epproducts_ajax[hotel]', this.hotelUidValue) - data.set('tx_epproducts_ajax[month]', selectedMonth.getMonth() + 1) - data.set('tx_epproducts_ajax[year]', selectedMonth.getFullYear()) - fetch(this.uriValue, { method: 'POST', body: data }) - .then(this.checkStatus) - .then(this.parseHTML) - .then(html => ( - this.containerTarget.innerHTML = html - )) - .finally(() => { - this.loadingValue = false - }) - } - - loadingValueChanged() { - this.loadingIndicatorTarget.classList.toggle('hidden', this.loadingValue === false) + select() { + const selectedIndex = parseInt(this.fieldTarget.value, 10) + this.monthTargets.forEach((month, index) => { + month.classList.toggle('hidden', index !== selectedIndex) + }) } } diff --git a/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/Availability.html b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/Availability.html new file mode 100644 index 0000000..f4936d6 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/Availability.html @@ -0,0 +1,62 @@ + + + + +
+ Der Kalender konnte aktuell nicht geladen werden. +
+
+ +
+ Für die Wintersaison liegen noch keine Belegungsdaten vor. +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ {weekday} +
+ + + + {f:format.date(format: 'd', date: day.date)} + + +   + + +
+
+
+
+
+ + diff --git a/packages/ep_theme/Resources/Private/Templates/Product/Detail.html b/packages/ep_theme/Resources/Private/Templates/Product/Detail.html index 2e1e818..b2138a4 100644 --- a/packages/ep_theme/Resources/Private/Templates/Product/Detail.html +++ b/packages/ep_theme/Resources/Private/Templates/Product/Detail.html @@ -171,6 +171,9 @@ + + +
@@ -363,6 +366,26 @@
+ +
+ + Belegungskalender + +
+
+ + Belegungskalender wird geladen … +
+
+ + + Zum Anfrageformular + + +
+
+
diff --git a/tailwind.config.js b/tailwind.config.js index 225bf4d..b629f5c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -30,7 +30,8 @@ module.exports = { 'bg-concept-sfl', 'bg-concept-sfl/70', 'bg-concept-ser', 'bg-concept-ser/70', 'flatpickr-input', 'flatpickr-calendar', 'flatpickr-day', 'flatpickr-day.selected', - 'availability-full-to-none', 'availability-full-to-partial', 'availability-none-to-full', + 'availability-full', 'availability-partial', 'availability-none', + 'availability-full-to-none','availability-full-to-partial', 'availability-none-to-full', 'availability-partial-to-full', 'availability-none-to-partial', 'availability-partial-to-none', ], theme: {