From baf7b5d94a60fe680a552666efbfee55f791c173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 20 Aug 2026 12:11:58 +0200 Subject: [PATCH] feat: improved caching of api-backed content --- .../Classes/BpnConnect/ApiClient.php | 42 +++++- .../Controller/AjaxCalendarController.php | 137 +++++++++++------- .../ep_products/Classes/MyEP/ApiClient.php | 118 ++++++++++++++- .../Configuration/TypoScript/setup.typoscript | 20 ++- .../ext/ep_products/ext_conf_template.txt | 12 ++ .../ext/ep_products/ext_localconf.php | 21 ++- .../Private/Templates/Product/Detail.html | 7 +- 7 files changed, 294 insertions(+), 63 deletions(-) diff --git a/public/typo3conf/ext/ep_products/Classes/BpnConnect/ApiClient.php b/public/typo3conf/ext/ep_products/Classes/BpnConnect/ApiClient.php index 5baf99b3..68fc1b8b 100644 --- a/public/typo3conf/ext/ep_products/Classes/BpnConnect/ApiClient.php +++ b/public/typo3conf/ext/ep_products/Classes/BpnConnect/ApiClient.php @@ -13,9 +13,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; class ApiClient { + /** + * Applied when extension configuration does not override them. Seconds. + */ + private const DEFAULT_TIMEOUT = 5; + private const DEFAULT_MAX_DURATION = 15; + public const CALENDAR_MODE_DAYS = 'days'; public const CALENDAR_MODE_RANGES = 'ranges'; + private ?array $configuration = null; + public function getCalendar( $hotelCode, \DateTimeInterface $dateFrom, @@ -84,7 +92,7 @@ class ApiClient private function getHttpClient(): HttpClientInterface { - $config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products'); + $config = $this->getConfiguration(); $baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? '')); $apiKey = trim((string)($config['bpnConnectApiKey'] ?? '')); @@ -95,10 +103,38 @@ class ApiClient throw new ApiException('Bpn Connect API key is not configured.'); } - return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', [ + $options = [ 'headers' => [ 'X-API-KEY' => $apiKey, ], - ]); + ]; + + // Without these the client falls back to PHP's default_socket_timeout (commonly 60s), + // so a stalled API pins a PHP worker for a minute per request. The defaults live in + // code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the + // stored configuration verbatim and only syncs the template when an extension has no + // configuration at all, so a newly added template key is absent until someone saves + // extension configuration by hand. Falling back to 0 there would silently mean "no + // timeout" - the opposite of what this guard is for. An explicit 0 still disables. + $timeout = (int)($config['bpnConnectApiTimeout'] ?? self::DEFAULT_TIMEOUT); + if ($timeout > 0) { + $options['timeout'] = $timeout; + } + $maxDuration = (int)($config['bpnConnectApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION); + if ($maxDuration > 0) { + $options['max_duration'] = $maxDuration; + } + + return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options); + } + + private function getConfiguration(): array + { + if (null === $this->configuration) { + $this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class) + ->get('ep_products'); + } + + return $this->configuration; } } diff --git a/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php b/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php index fef00ac9..fe14cb66 100644 --- a/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php +++ b/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php @@ -34,6 +34,7 @@ use EP\EpProducts\MyEP\ApiException; use EP\EpProducts\Utility\DateUtility; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; +use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; class AjaxCalendarController extends ActionController { @@ -53,6 +54,8 @@ class AjaxCalendarController extends ActionController public function priceCalendarAction(Hotel $hotel, int $months = 12): void { $hotelCode = trim($hotel->getCode() ?? ''); + $this->tagPageCacheForHotel($hotelCode); + $dateRange = DateUtility::getDateRange(null, null, $months); $today = new \DateTimeImmutable('today'); @@ -62,70 +65,63 @@ class AjaxCalendarController extends ActionController if ($hotelCode !== '') { try { - $cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [ + // 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, - $dateRange['period']->getStartDate()->format('Y-m-d'), - $dateRange['period']->getEndDate()->format('Y-m-d'), - ])); - $cachedData = $this->cache->get($cacheIdentifier); + $dateRange['period']->getStartDate(), + $dateRange['period']->getEndDate() + ); - if (false !== $cachedData) { - $priceDataByDate = $cachedData; - } else { - $rawData = $this->myEpClient->getPriceConfigData( - $hotelCode, - $dateRange['period']->getStartDate(), - $dateRange['period']->getEndDate() - ); + $availableDates = []; + foreach ($rawData as $item) { + if (($item['status'] ?? '') === 'OK') { + $availableDates[$item['date']] = true; + } + } - $availableDates = []; - foreach ($rawData as $item) { - if (($item['status'] ?? '') === 'OK') { - $availableDates[$item['date']] = true; - } + foreach ($rawData as $item) { + $date = $item['date']; + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date); + if ($dt < $today) { + continue; } - foreach ($rawData as $item) { - $date = $item['date']; - $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date); - if ($dt < $today) { - continue; - } + $prevDate = $dt->modify('-1 day')->format('Y-m-d'); + $isAvailable = ($item['status'] ?? '') === 'OK'; + $prevAvailable = isset($availableDates[$prevDate]); - $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 ($isAvailable) { + $dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok'; + } elseif ($prevAvailable) { + $dayStatus = 'checkout-only'; + } else { + $dayStatus = 'blocked'; } - $this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL); + $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), + ]; } } catch (ApiException $e) { $error = true; $errorMessage = $e->getMessage(); + $this->disablePageCache(); } } else { $error = true; $errorMessage = 'Missing hotel code'; + $this->disablePageCache(); } $this->view->assignMultiple([ @@ -143,6 +139,7 @@ class AjaxCalendarController extends ActionController $apiDateFrom = $dateRange['period']->getStartDate(); $apiDateTo = $dateRange['period']->getEndDate(); $hotelCode = trim($hotel->getCode() ?? ''); + $this->tagPageCacheForHotel($hotelCode); $priceConfig = [ 'data' => [], @@ -155,6 +152,7 @@ class AjaxCalendarController extends ActionController } catch (ApiException $e) { $priceConfig['success'] = false; $priceConfig['error'] = $e->getMessage(); + $this->disablePageCache(); } $this->view->assign('priceConfig', $priceConfig); @@ -164,6 +162,7 @@ class AjaxCalendarController extends ActionController { $year = $year ?? (new \DateTimeImmutable())->format('Y'); $hotelCode = trim($hotel->getCode() ?? ''); + $this->tagPageCacheForHotel($hotelCode); $priceTable = [ 'data' => [], @@ -176,6 +175,7 @@ class AjaxCalendarController extends ActionController } catch (ApiException $e) { $priceTable['success'] = false; $priceTable['error'] = $e->getMessage(); + $this->disablePageCache(); } $this->view->assignMultiple([ @@ -212,7 +212,7 @@ class AjaxCalendarController extends ActionController $hotelCode, $apiDateFrom, $apiDateTo, - ApiClient::CALENDAR_MODE_DAYS + BpnConnectClient::CALENDAR_MODE_DAYS ); $cachedCalendar = $this->cache->get($cacheIdentifier); @@ -223,7 +223,7 @@ class AjaxCalendarController extends ActionController $hotelCode, new \DateTimeImmutable($apiDateFrom), new \DateTimeImmutable($apiDateTo), - ApiClient::CALENDAR_MODE_DAYS + BpnConnectClient::CALENDAR_MODE_DAYS ); $this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL); } @@ -240,6 +240,43 @@ class AjaxCalendarController extends ActionController ]); } + /** + * 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 stored alongside the entry by + * TypoScriptFrontendController::setPageCacheContent(). + * + * Used manually today, when one hotel's availability needs re-fetching before its hour + * is up: + * + * typo3cms cache:flushtags ep_hotel_ --groups pages + */ + private function tagPageCacheForHotel(string $hotelCode): void + { + if ('' === $hotelCode) { + return; + } + + if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) { + $GLOBALS['TSFE']->addCacheTags(['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. + * + * The second argument must be true: set_no_cache() ignores the call otherwise, because + * $TYPO3_CONF_VARS['FE']['disableNoCacheParameter'] defaults to true. + */ + private function disablePageCache(): void + { + if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) { + $GLOBALS['TSFE']->set_no_cache('Price calendar data could not be retrieved', true); + } + } + private function buildTooltip(string $status, array $item): string { if ($status === 'blocked') { diff --git a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php index 3c3152de..67fe1da2 100644 --- a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php +++ b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php @@ -16,6 +16,8 @@ use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; +use TYPO3\CMS\Core\Cache\CacheManager; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -23,8 +25,36 @@ class ApiClient implements LoggerAwareInterface { use LoggerAwareTrait; + /** + * Applied when extension configuration does not override them. Seconds. + */ + private const DEFAULT_TIMEOUT = 5; + private const DEFAULT_MAX_DURATION = 15; + + /** + * Cache identifier for the shared OAuth access token. The token is valid for the whole + * installation, so caching it here saves one round trip per request that would otherwise + * have to re-authenticate before it can fetch anything. + */ + private const ACCESS_TOKEN_CACHE_IDENTIFIER = 'myep_api_access_token'; + + /** + * Safety margin in seconds. A token that is about to expire is treated as expired so it + * cannot lapse between the cache read and the API call that uses it. + */ + private const ACCESS_TOKEN_EXPIRY_MARGIN = 60; + + /** + * Fallback lifetime for tokens that do not report an expiry. + */ + private const ACCESS_TOKEN_DEFAULT_TTL = 300; + private static ?AccessToken $accessToken = null; + private ?FrontendInterface $cache = null; + + private ?array $configuration = null; + /** * @throws ApiException */ @@ -209,14 +239,58 @@ class ApiClient implements LoggerAwareInterface private function getHttpClient(): HttpClientInterface { - $config = GeneralUtility::makeInstance(ExtensionConfiguration::class) - ->get('ep_products'); + $config = $this->getConfiguration(); static::$accessToken = $this->getAccessToken($config); - return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [ + $options = [ 'auth_bearer' => static::$accessToken->getToken(), - ]); + ]; + + // Without these the client falls back to PHP's default_socket_timeout (commonly 60s), + // so a stalled API pins a PHP worker for a minute per request. The defaults live in + // code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the + // stored configuration verbatim and only syncs the template when an extension has no + // configuration at all, so a newly added template key is absent until someone saves + // extension configuration by hand. Falling back to 0 there would silently mean "no + // timeout" - the opposite of what this guard is for. An explicit 0 still disables. + $timeout = (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT); + if ($timeout > 0) { + $options['timeout'] = $timeout; + } + $maxDuration = (int)($config['myEpApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION); + if ($maxDuration > 0) { + $options['max_duration'] = $maxDuration; + } + + return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options); + } + + /** + * Resolved lazily rather than injected: this class is also instantiated through Extbase's + * legacy object container (EP\EpTheme\Form\BpnApiFinisher asks for it via injectApiClient), + * and that container resolves constructor arguments by reflection, mapping an interface to + * a class name by stripping the "Interface" suffix (Container::getImplementationClassName()). + * A constructor typed against FrontendInterface therefore fatals there with + * "Class TYPO3\CMS\Core\Cache\Frontend\Frontend does not exist". + */ + private function getCache(): FrontendInterface + { + if (null === $this->cache) { + $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('ep_products_cache'); + } + + return $this->cache; + } + + private function getConfiguration(): array + { + if (null === $this->configuration) { + $this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class) + ->get('ep_products'); + } + + return $this->configuration; } /** @@ -228,8 +302,21 @@ class ApiClient implements LoggerAwareInterface return static::$accessToken; } + // The static above only lives for one PHP request, so without this second level every + // single request would re-authenticate before it could fetch any data. + $cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER); + if (is_array($cachedToken) && isset($cachedToken['access_token'])) { + $token = new AccessToken($cachedToken); + if (false === $token->hasExpired()) { + static::$accessToken = $token; + + return static::$accessToken; + } + } + try { static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials'); + $this->cacheAccessToken(static::$accessToken); } catch (IdentityProviderException $e) { $message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage()); $this->logError($message, ['exception' => $e]); @@ -247,9 +334,32 @@ class ApiClient implements LoggerAwareInterface return static::$accessToken; } + private function cacheAccessToken(AccessToken $token): void + { + $expires = $token->getExpires(); + + if (null === $expires) { + $lifetime = self::ACCESS_TOKEN_DEFAULT_TTL; + } else { + $lifetime = $expires - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN; + } + + if ($lifetime <= 0) { + return; + } + + $this->getCache()->set(self::ACCESS_TOKEN_CACHE_IDENTIFIER, $token->jsonSerialize(), [], $lifetime); + } + private function getProvider(array $config): AbstractProvider { return new GenericProvider([ + // The OAuth round trip goes through the provider's own Guzzle client, not the + // Symfony client configured in getHttpClient(), so it needs its own timeout - + // otherwise the token request hangs indefinitely and the data-request timeout + // never gets a chance to apply. AbstractProvider::getAllowedClientOptions() + // forwards 'timeout' (and 'proxy') into Guzzle; 0 means unlimited there too. + 'timeout' => (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT), 'clientId' => $config['myEpApiClientId'], 'clientSecret' => $config['myEpApiClientSecret'], 'redirectUri' => null, diff --git a/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript b/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript index a7002221..a5378bb5 100644 --- a/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript +++ b/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript @@ -144,6 +144,25 @@ tx_epproducts_ajax_json { xhtml_cleaning = 0 admPanel = 0 debug = 0 + + # The calendar actions are cacheable, but they render volatile availability data. + # TSFE would otherwise fall back to cacheTimeOutDefault (86400), which would widen + # staleness from one hour to a full day. Keep this aligned with the freshness budget. + cache_period = 3600 + # priceCalendarAction renders relative to 'today', so an entry must not outlive the + # day it was rendered on. Near midnight this clips the lifetime below cache_period. + cache_clearAtMidnight = 1 + + # The server-side page cache can be flushed; a max-age already sitting in a visitor's + # browser cannot. Availability is volatile enough that we keep the speed win on the + # server and make clients revalidate every time - they still hit a warm page cache. + # sendCacheHeaders is off so TSFE does not also emit a contradicting Expires/Pragma. + sendCacheHeaders = 0 + additionalHeaders.30.header = Cache-Control: private, no-cache, must-revalidate + + # Same reasoning: staticfilecache serves from disk without touching PHP, which is the + # layer with the least predictable invalidation. Not worth it for volatile fragments. + tx_staticfilecache.disableCache = 1 } 10 = USER @@ -193,7 +212,6 @@ tx_epproducts_ajax_json { 1 = concept } } - features.requireCHashArgumentForActionArguments = 0 settings =< plugin.tx_epproducts.settings persistence =< plugin.tx_epproducts.persistence view =< plugin.tx_epproducts.view diff --git a/public/typo3conf/ext/ep_products/ext_conf_template.txt b/public/typo3conf/ext/ep_products/ext_conf_template.txt index 051caf9d..0e7d3be8 100644 --- a/public/typo3conf/ext/ep_products/ext_conf_template.txt +++ b/public/typo3conf/ext/ep_products/ext_conf_template.txt @@ -22,8 +22,20 @@ myEpApiUrlAuthorize = # cat=MyEpAPI; type=string; label=MyE&P API token URL myEpApiUrlToken = +# cat=MyEpAPI; type=int; label=MyE&P API inactivity timeout in seconds (0 = PHP default_socket_timeout) +myEpApiTimeout = 5 + +# cat=MyEpAPI; type=int; label=MyE&P API max total request duration in seconds (0 = unlimited) +myEpApiMaxDuration = 15 + # cat=MyEpAPI; type=string; label=Bpn Connect API base URL bpnConnectApiBaseUrl = # cat=MyEpAPI; type=string; label=Bpn Connect API key bpnConnectApiKey = + +# cat=MyEpAPI; type=int; label=Bpn Connect API inactivity timeout in seconds (0 = PHP default_socket_timeout) +bpnConnectApiTimeout = 5 + +# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited) +bpnConnectApiMaxDuration = 15 diff --git a/public/typo3conf/ext/ep_products/ext_localconf.php b/public/typo3conf/ext/ep_products/ext_localconf.php index c7c90b4c..6e0e7d06 100644 --- a/public/typo3conf/ext/ep_products/ext_localconf.php +++ b/public/typo3conf/ext/ep_products/ext_localconf.php @@ -6,6 +6,15 @@ $boot = function () { $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['ep_products_cache'] = []; } + // The cacheable AjaxCalendar actions are only distinguished in the page-cache key when a + // cHash is present - TypoScriptFrontendController::getRelevantParametersForCachingFromPageArguments() + // drops all arguments without one, so every hotel would otherwise collide on a single + // entry and be served another hotel's availability, silently and with no 404 to signal it. + // Every URL is generated by the Uri\Ajax ViewHelper and carries a cHash; requiring it here + // turns a hand-built or truncated URL into a loud 404 instead of quietly wrong data. + // Client-side callers pass these parameters in POST bodies, which cHash never inspects. + $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['requireCacheHashPresenceParameters'][] = '^tx_epproducts_ajax'; + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['ep_products'] = \EP\EpProducts\Hooks\Tcemain::class; @@ -278,7 +287,7 @@ $boot = function () { 'AjaxDate' => 'dates', 'AjaxContingent' => 'list,rooms', 'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms', - 'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable', + 'AjaxCalendar' => 'range', 'AjaxWatchlist' => 'list', 'AjaxTeaserPopup' => 'concept', ] @@ -311,9 +320,13 @@ $boot = function () { $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ref'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'pa'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ep_staging'; - $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[months]'; - $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[month]'; - $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[year]'; + // NOTE: tx_epproducts_ajax[months|month|year] were previously excluded from the cHash. + // That was harmless while every AjaxCalendar action was non-cacheable, but priceCalendar, + // priceConfig and priceTable are cacheable now, and an excluded parameter is dropped from + // the page-cache key as well - so the 2026 and 2027 price tables shared one entry and the + // second year was served the first year's content. They must take part in the cHash. + // The client-side callers that vary month/year (calendar_controller.js) send them in a + // POST body, which the cHash never inspects, so nothing depends on the exclusion. $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'f'; $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects']['PassthroughMapper'] = diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html index 57359835..a127c11c 100644 --- a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html @@ -358,8 +358,13 @@ +
+ hx-trigger="revealed">