diff --git a/config/system/secrets.example.php b/config/system/secrets.example.php index e4bf440..edd57c2 100644 --- a/config/system/secrets.example.php +++ b/config/system/secrets.example.php @@ -17,9 +17,6 @@ return [ 'myEpApiClientSecret' => '', 'myEpApiUrlAuthorize' => '', 'myEpApiUrlToken' => '', - // bpn-connect (price calendar) - 'bpnConnectApiBaseUrl' => '', - 'bpnConnectApiKey' => '', ], ], ]; diff --git a/packages/ep_products/Classes/Controller/AjaxCalendarController.php b/packages/ep_products/Classes/Controller/AjaxCalendarController.php new file mode 100644 index 0000000..c928dcd --- /dev/null +++ b/packages/ep_products/Classes/Controller/AjaxCalendarController.php @@ -0,0 +1,289 @@ +, 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> + */ + 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 + */ + 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, + ]; + } + +} diff --git a/packages/ep_products/ext_localconf.php b/packages/ep_products/ext_localconf.php index caf841c..a699a50 100644 --- a/packages/ep_products/ext_localconf.php +++ b/packages/ep_products/ext_localconf.php @@ -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', ], [ diff --git a/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html new file mode 100644 index 0000000..a2b1062 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html @@ -0,0 +1,76 @@ + + + + +
+ Der Kalender konnte aktuell nicht geladen werden. +
+ +
+ +
+ + +
+
+ +
+
+ {month.date} +
+ + + + + + + + + + + + + + + + + + + + + +
MoDiMiDoFrSaSo
+ + + + + + + +
 
+
+
+
+
+
+
+
+
+ + diff --git a/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html new file mode 100644 index 0000000..179997d --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html @@ -0,0 +1,79 @@ + + + +
+

+ Preise {year} +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ von + + bis + + Saison + + Pax bis + + Mindestmiete pro Nacht + + weitere Personen pro Nacht +
+ {row.dateFrom -> f:format.date(format: 'd.m.Y')} + + {row.dateTo -> f:format.date(format: 'd.m.Y')} + + + VNS + NS + HS + {row.season} + + + {row.includedPax} + + + + + {row.defaultPricePerNight -> f:format.currency(currencySign: row.currency)} + + {row.pricePerNight -> f:format.currency(currencySign: row.currency)} + + + {row.pricePerNight -> f:format.currency(currencySign: row.currency)} + + + + {row.priceAdditionalPerson -> f:format.currency(currencySign: row.currency)} +
+
+ VNS = Vorteils-Nebensaison, NS = Nebensaison, HS = Hauptsaison +
+
+
+ +