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:
1 parent
294d270bc1
commit
c1b4979583
5 files changed
+446
-3
No files matched your search
@@ -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,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>← 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 →</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"> </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>
|
||||
Reference in new issue
Block a user