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 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-29 14:03:59 +01:00
1 parent c63cbdd3a6
commit 5d41660a9a
6 files changed
+202 -58

No files matched your search

@@ -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') {
+1 -1
View File
@@ -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',
],
[
@@ -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
select() {
const selectedIndex = parseInt(this.fieldTarget.value, 10)
this.monthTargets.forEach((month, index) => {
month.classList.toggle('hidden', index !== selectedIndex)
})
}
loadingValueChanged() {
this.loadingIndicatorTarget.classList.toggle('hidden', this.loadingValue === false)
}
}
@@ -0,0 +1,62 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:if condition="{error}">
<f:then>
<div class="text-sm text-red-600">
Der Kalender konnte aktuell nicht geladen werden.
</div>
</f:then>
<f:else if="{calendarMonths -> f:count()} == 0">
<div class="text-sm">
Für die Wintersaison liegen noch keine Belegungsdaten vor.
</div>
</f:else>
<f:else>
<div data-controller="calendar">
<select class="w-full border-zinc-400 focus:border-zinc-400"
data-calendar-target="field"
data-action="calendar#select">
<f:for each="{calendarMonths}" as="month" iteration="i">
<option value="{i.index}"><f:format.date format="%B %Y">{month.date}</f:format.date></option>
</f:for>
</select>
<f:for each="{calendarMonths}" as="month" iteration="i">
<table class="min-w-full border border-zinc-400 border-collapse mt-4 mb-0{f:if(condition: i.isFirst, else: ' hidden')}"
data-calendar-target="month">
<thead>
<tr>
<f:for each="{0: 'Mo', 1: 'Di', 2: 'Mi', 3: 'Do', 4: 'Fr', 5: 'Sa', 6: 'So'}" as="weekday">
<th class="px-1 py-2 border border-zinc-300 text-center">
{weekday}
</th>
</f:for>
</tr>
</thead>
<tbody>
<f:for each="{month.weeks}" as="week">
<tr>
<f:for each="{week}" as="day">
<td class="px-1 py-2 border border-zinc-300 text-center">
<f:if condition="{day.inMonth}">
<f:then>
<f:variable name="dateKey" value="{f:format.date(format: 'Y-m-d', date: day.date)}"/>
<span class="block p-1 leading-none text-sm {classByDate.{dateKey}}">{f:format.date(format: 'd', date: day.date)}</span>
</f:then>
<f:else>
&nbsp;
</f:else>
</f:if>
</td>
</f:for>
</tr>
</f:for>
</tbody>
</table>
</f:for>
</div>
</f:else>
</f:if>
</html>
@@ -171,6 +171,9 @@
<f:if condition="{showGallery}">
<f:render section="SidebarGallery" arguments="{_all}"/>
</f:if>
<f:if condition="{product.calendarHotel} && {product.season} != 's'">
<f:render section="AvailabilityCalendar" arguments="{_all}"/>
</f:if>
<f:if condition="{product.tourLink}">
<div class="bg-zinc-50 p-4 mb-4"
data-controller="lightbox">
@@ -363,6 +366,26 @@
</div>
</f:section>
<f:section name="AvailabilityCalendar">
<div class="bg-zinc-50 p-4 flex flex-col space-y-2 mb-4">
<span class="block font-bold">
Belegungskalender
</span>
<div hx-get="{ep:uri.ajax(action: 'availability', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel}', format: 'html')}"
hx-trigger="intersect once">
<div class="flex items-center justify-center space-x-4 text-sm p-4">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
<span>Belegungskalender wird geladen …</span>
</div>
</div>
<f:if condition="{product.concept.code} == 'gsr'">
<f:link.typolink class="button bg-button" parameter="{settings.groupsContactFormPageUid}" additionalParams="&ref={currentPageUid}">
Zum Anfrageformular
</f:link.typolink>
</f:if>
</div>
</f:section>
<f:section name="TableLoadingIndicator">
<div class="flex items-center justify-center space-x-4 text-sm p-4">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
+1
View File
@@ -30,6 +30,7 @@ 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', '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',
],