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:
1 parent
c63cbdd3a6
commit
5d41660a9a
6 files changed
+203
-59
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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
</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=""/>
|
||||
|
||||
+2
-1
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user