feat: price calendar and price table for group houses

Ports AjaxCalendarController::priceCalendarAction and priceTableAction
(MyE&P price config and price table), used on the /gruppe/gruppenhaus-*
pages and example trips. Page cache tags and "do not cache" on API
errors go through the frontend.cache.collector and
frontend.cache.instruction request attributes instead of TSFE.

Left out: priceConfig (no caller) and range (daytrip calendar via
bpn-connect, out of scope), so bpn-connect needs no credentials.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-21 15:11:12 +02:00
1 parent 294d270bc1
commit c1b4979583
5 files changed
+446 -3

No files matched your search

-3
View File
@@ -17,9 +17,6 @@ return [
'myEpApiClientSecret' => '',
'myEpApiUrlAuthorize' => '',
'myEpApiUrlToken' => '',
// bpn-connect (price calendar)
'bpnConnectApiBaseUrl' => '',
'bpnConnectApiKey' => '',
],
],
];
@@ -0,0 +1,289 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\MyEP\ApiClient as MyEpClient;
use EP\EpProducts\MyEP\ApiException;
use EP\EpProducts\Utility\DateUtility;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class AjaxCalendarController extends ActionController
{
protected MyEpClient $myEpClient;
public function __construct(MyEpClient $myEpClient)
{
$this->myEpClient = $myEpClient;
}
public function priceCalendarAction(Hotel $hotel, int $months = 12): ResponseInterface
{
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$dateRange = DateUtility::getDateRange(null, null, $months);
$period = $dateRange['period'];
$today = new \DateTimeImmutable('today');
$priceDataByDate = [];
$error = false;
$errorMessage = '';
if ($hotelCode !== '') {
try {
// This action is cacheable, so the rendered markup is held in the page cache
// for config.cache_period. An additional application-level cache here would
// key on exactly the same granularity (hotel + date range) and only stack a
// second TTL on top, pushing worst-case staleness past the freshness budget.
$rawData = $this->myEpClient->getPriceConfigData($hotelCode);
$availableDates = [];
foreach ($rawData as $item) {
if (($item['status'] ?? '') === 'OK') {
$availableDates[$item['date']] = true;
}
}
// The API no longer honours a date-range constraint, so it may return data
// further out than the default $months grid covers. Grow the grid to match
// whatever the API actually returned rather than silently dropping the tail.
$lastDataDate = null;
foreach ($rawData as $item) {
$date = $item['date'];
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($dt < $today) {
continue;
}
if (null === $lastDataDate || $dt > $lastDataDate) {
$lastDataDate = $dt;
}
$prevDate = $dt->modify('-1 day')->format('Y-m-d');
$isAvailable = ($item['status'] ?? '') === 'OK';
$prevAvailable = isset($availableDates[$prevDate]);
if ($isAvailable) {
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
} elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
} else {
$dayStatus = 'blocked';
}
$priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
}
if (null !== $lastDataDate) {
$dataEnd = $lastDataDate->modify('first day of next month')->setTime(0, 0, 0);
if ($dataEnd > $period->getEndDate()) {
$period = new \League\Period\Period($period->getStartDate(), $dataEnd);
}
}
} catch (ApiException $e) {
$error = true;
$errorMessage = $e->getMessage();
$this->disablePageCache();
}
} else {
$error = true;
$errorMessage = 'Missing hotel code';
$this->disablePageCache();
}
$this->view->assignMultiple([
'priceDataByDate' => $priceDataByDate,
'calendarMonths' => $this->buildCalendarMonths($period),
'error' => $error,
'errorMessage' => $errorMessage,
]);
return $this->htmlResponse();
}
public function priceTableAction(Hotel $hotel, ?string $year = null): ResponseInterface
{
$year = $year ?? (new \DateTimeImmutable())->format('Y');
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceTable = [
'data' => [],
'success' => true,
'error' => null,
];
try {
$priceTable['data'] = $this->myEpClient->getPriceTableData($hotelCode, $year);
} catch (ApiException $e) {
$priceTable['success'] = false;
$priceTable['error'] = $e->getMessage();
$this->disablePageCache();
}
$this->view->assignMultiple([
'hotel' => $hotel,
'year' => $year,
'priceTable' => $priceTable,
]);
return $this->htmlResponse();
}
/**
* Tag this page-cache entry with the hotel it renders, so the entry can be flushed for
* that hotel alone rather than by clearing the whole page cache and re-warming every
* calendar. Tags are collected by the frontend cache data collector.
*
* Used manually today, when one hotel's availability needs re-fetching before its hour
* is up (no core CLI command for tags; e.g. from a small script via CacheManager).
*/
private function tagPageCacheForHotel(string $hotelCode): void
{
if ('' === $hotelCode) {
return;
}
$this->request->getAttribute('frontend.cache.collector')?->addCacheTags(new CacheTag('ep_hotel_' . $hotelCode));
}
/**
* The calendar actions are cacheable, so an API failure would otherwise pin the error
* markup in the page cache for the full cache_period. Drop caching for this request so
* the next visitor gets a fresh attempt instead of an hour-old error message.
*/
private function disablePageCache(): void
{
$this->request->getAttribute('frontend.cache.instruction')?->disableCache('Price calendar data could not be retrieved');
}
private function buildTooltip(string $status, array $item): string
{
if ($status === 'blocked') {
return 'Nicht verfügbar';
}
if ($status === 'checkout-only') {
return 'Nur Abreise';
}
return $this->buildPriceTooltip($item);
}
private function buildPriceTooltip(array $item): string
{
$price = $item['pricePerNight'] ?? null;
$currency = $this->getCurrencySymbol($item['currency'] ?? null);
if ($price !== null) {
$priceStr = (($item['type'] ?? null) === 'discount')
? $price . ' ' . $currency
: 'ab ' . $price . ' ' . $currency;
} else {
$priceStr = 'Anfrage';
}
$minNights = (int)($item['minNights'] ?? 0);
return $minNights > 1
? $priceStr . ' · min. ' . $minNights . ' Nächte'
: $priceStr;
}
private function getCurrencySymbol(?string $currency): string
{
if (null === $currency || '' === $currency) {
return '€';
}
return 'EUR' === $currency ? '€' : $currency;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildCalendarMonths(\League\Period\Period $period): array
{
$months = [];
$current = new \DateTimeImmutable($period->getStartDate()->format('Y-m-01 00:00:00'));
$lastDay = $period->getEndDate()->sub(new \DateInterval('P1D'));
$lastMonthStart = new \DateTimeImmutable($lastDay->format('Y-m-01 00:00:00'));
while ($current <= $lastMonthStart) {
$months[] = $this->buildCalendarMonth($current);
$current = $current->modify('first day of next month');
}
return $months;
}
/**
* @return array<string, mixed>
*/
private function buildCalendarMonth(\DateTimeImmutable $monthStart): array
{
$firstOfMonth = $monthStart->setTime(0, 0, 0);
$lastOfMonth = $firstOfMonth->modify('last day of this month');
$gridStart = $firstOfMonth->modify('monday this week');
$gridEnd = $lastOfMonth->modify('sunday this week');
$weeks = [];
$week = [];
$current = $gridStart;
while ($current <= $gridEnd) {
$week[] = [
'date' => $current,
'inMonth' => $current->format('Y-m') === $firstOfMonth->format('Y-m'),
];
if (count($week) === 7) {
$weeks[] = $week;
$week = [];
}
$current = $current->add(new \DateInterval('P1D'));
}
return [
'date' => $firstOfMonth,
'weeks' => $weeks,
];
}
}
+2
View File
@@ -2,6 +2,7 @@
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
use EP\EpProducts\Controller\ProductController;
use EP\EpProducts\Controller\AjaxTableController;
use EP\EpProducts\Controller\AjaxCalendarController;
use EP\EpProducts\Controller\AjaxTeaserPopupController;
use EP\EpProducts\Routing\Aspect\PassthroughMapper;
use TYPO3\CMS\Core\Log\LogLevel;
@@ -52,6 +53,7 @@ $boot = function () {
'ajax',
[
AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable',
AjaxCalendarController::class => 'priceCalendar,priceTable',
AjaxTeaserPopupController::class => 'concept',
],
[
@@ -0,0 +1,76 @@
<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="p-4 text-sm text-red-600">
Der Kalender konnte aktuell nicht geladen werden.
</div>
<div class="hidden" data-price-calendar-error-detail>{errorMessage}</div>
</f:then>
<f:else>
<div class="flex items-center justify-between mb-4">
<button type="button" class="px-3 py-1 text-sm bg-zinc-100 hover:bg-zinc-200 rounded transition-colors disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none"
data-price-calendar-target="prevButton"
data-action="click->price-calendar#prevMonths"
disabled>&#8592; früher</button>
<button type="button" class="px-3 py-1 text-sm bg-zinc-100 hover:bg-zinc-200 rounded transition-colors disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none"
data-price-calendar-target="nextButton"
data-action="click->price-calendar#nextMonths">später &#8594;</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<f:for each="{calendarMonths}" as="month" iteration="i">
<div data-month-index="{i.index}" data-price-calendar-target="month">
<div class="font-semibold text-center py-2 text-zinc-700">
<f:format.date format="%B %Y">{month.date}</f:format.date>
</div>
<table class="w-full border-collapse">
<thead>
<tr>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Mo</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Di</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Mi</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Do</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Fr</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Sa</th>
<th class="py-1 text-center text-xs text-zinc-500 font-normal">So</th>
</tr>
</thead>
<tbody>
<f:for each="{month.weeks}" as="week">
<tr>
<f:for each="{week}" as="day">
<td class="p-px">
<f:if condition="{day.inMonth}">
<f:then>
<f:variable name="dateKey" value="{f:format.date(format: 'Y-m-d', date: day.date)}"/>
<f:variable name="dayData" value="{priceDataByDate.{dateKey}}"/>
<button type="button"
class="pc-day"
data-price-calendar-target="day"
data-action="click->price-calendar#selectDate"
data-date="{dateKey}"
data-status="{dayData.status}"
data-min-nights="{dayData.minNights}"
<f:if condition="{dayData.tooltip}">aria-label="{dayData.tooltip}" data-cooltipz-dir="top"</f:if>>
{f:format.date(format: 'j', date: day.date)}
</button>
</f:then>
<f:else>
<div class="pc-day">&nbsp;</div>
</f:else>
</f:if>
</td>
</f:for>
</tr>
</f:for>
</tbody>
</table>
</div>
</f:for>
</div>
</f:else>
</f:if>
</html>
@@ -0,0 +1,79 @@
<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="{priceTable.success} && {priceTable.data -> f:count()}">
<div class="w-full overflow-x-auto mb-4">
<h3 class="font-bold mt-4 mb-2">
Preise {year}
</h3>
<table class="w-full mb-4 bg-zinc-200">
<thead>
<tr>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
von
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
bis
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
Saison
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
Pax bis
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
Mindestmiete pro Nacht
</th>
<th class="px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
weitere Personen pro Nacht
</th>
</tr>
</thead>
<tbody>
<f:for each="{priceTable.data}" as="row">
<tr class="odd:bg-zinc-50 hover:bg-zinc-100 odd:hover:bg-zinc-100">
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
{row.dateFrom -> f:format.date(format: 'd.m.Y')}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
{row.dateTo -> f:format.date(format: 'd.m.Y')}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
<f:switch expression="{row.season}">
<f:case value="adv_secondary">VNS</f:case>
<f:case value="secondary">NS</f:case>
<f:case value="peak">HS</f:case>
<f:defaultCase>{row.season}</f:defaultCase>
</f:switch>
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-right text-sm md:text-base">
{row.includedPax}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-right text-sm md:text-base">
<f:if condition="{row.type} == 'discount'">
<f:then>
<f:if condition="{row.defaultPricePerNight}">
<span class="line-through text-zinc-400 mr-1">{row.defaultPricePerNight -> f:format.currency(currencySign: row.currency)}</span>
</f:if>
{row.pricePerNight -> f:format.currency(currencySign: row.currency)}
</f:then>
<f:else>
{row.pricePerNight -> f:format.currency(currencySign: row.currency)}
</f:else>
</f:if>
</td>
<td class="px-2 py-1 md:p-2 text-right text-sm md:text-base">
{row.priceAdditionalPerson -> f:format.currency(currencySign: row.currency)}
</td>
</tr>
</f:for>
</tbody>
</table>
<div>
VNS = Vorteils-Nebensaison, NS = Nebensaison, HS = Hauptsaison
</div>
</div>
</f:if>
</html>