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: {