Compare commits

...
3 Commits
Author SHA1 Message Date
Björn Fromme 25789612bf wip: groups bookings calendar 2026-07-14 16:32:15 +02:00
Björn Fromme 9c5a6747bc feat: consolidate event search teasers
addresses #869e4n8py
2026-07-14 16:29:21 +02:00
Björn Fromme 12bcb9360c feat: include concept popup data in teaser data for eligibility check 2026-07-14 14:31:46 +02:00
23 changed files with 867 additions and 101 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ GET https://ep-reisen.ddev.site/api/product
### GET hotel data for myep
GET https://ep-reisen.ddev.site/api/hotel
?hotel=SSTGR
?hotel=DPWGRP
&key=abcabc123
###
-16
View File
@@ -9197,22 +9197,6 @@
"node": ">=8.10.0"
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
@@ -27,8 +27,10 @@ namespace EP\EpProducts\Controller;
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\BpnConnect\ApiClient;
use EP\EpProducts\BpnConnect\ApiClient as BpnConnectClient;
use EP\EpProducts\MyEP\ApiClient as MyEpClient;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\MyEP\ApiException;
use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
@@ -37,26 +39,147 @@ class AjaxCalendarController extends ActionController
{
private const CACHE_TTL = 3600;
/**
* @var ApiClient
*/
protected $apiClient;
protected BpnConnectClient $bpnConnectClient;
protected MyEpClient $myEpClient;
protected FrontendInterface $cache;
/**
* @var FrontendInterface
*/
protected $cache;
/**
* @param ApiClient $apiClient
* @param FrontendInterface $cache
*/
public function __construct(ApiClient $apiClient, FrontendInterface $cache)
public function __construct(BpnConnectClient $bpnConnectClient, MyEpClient $myEpClient, FrontendInterface $cache)
{
$this->apiClient = $apiClient;
$this->bpnConnectClient = $bpnConnectClient;
$this->myEpClient = $myEpClient;
$this->cache = $cache;
}
public function priceCalendarAction(Hotel $hotel, int $months = 12): void
{
$hotelCode = trim($hotel->getCode() ?? '');
$dateRange = DateUtility::getDateRange(null, null, $months);
$today = new \DateTimeImmutable('today');
$priceDataByDate = [];
$error = false;
if ($hotelCode !== '') {
try {
$cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [
$hotelCode,
$dateRange['period']->getStartDate()->format('Y-m-d'),
$dateRange['period']->getEndDate()->format('Y-m-d'),
]));
$cachedData = $this->cache->get($cacheIdentifier);
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;
}
}
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]);
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,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
}
$this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL);
}
} catch (ApiException $e) {
$error = true;
}
} else {
$error = true;
}
$this->view->assignMultiple([
'priceDataByDate' => $priceDataByDate,
'calendarMonths' => $this->buildCalendarMonths($dateRange['period']),
'error' => $error,
]);
}
public function priceConfigAction(Hotel $hotel, string $year = null, string $month = null, int $months = 12)
{
$dateRange = DateUtility::getDateRange($year, $month, $months);
$apiDateFrom = $dateRange['period']->getStartDate();
$apiDateTo = $dateRange['period']->getEndDate();
$hotelCode = trim($hotel->getCode() ?? '');
$priceConfig = [
'data' => [],
'success' => true,
'error' => null,
];
try {
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode, $apiDateFrom, $apiDateTo);
} catch (ApiException $e) {
$priceConfig['success'] = false;
$priceConfig['error'] = $e->getMessage();
}
$this->view->assign('priceConfig', $priceConfig);
}
public function priceTableAction(Hotel $hotel, string $year = null): void
{
$year = $year ?? (new \DateTimeImmutable())->format('Y');
$hotelCode = trim($hotel->getCode() ?? '');
$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->view->assignMultiple([
'hotel' => $hotel,
'year' => $year,
'priceTable' => $priceTable,
]);
}
/**
* @param Hotel $hotel
* @param string $year
@@ -91,7 +214,7 @@ class AjaxCalendarController extends ActionController
if (false !== $cachedCalendar) {
$calendar = $cachedCalendar;
} else {
$calendar = $this->apiClient->getCalendar(
$calendar = $this->bpnConnectClient->getCalendar(
$hotelCode,
new \DateTimeImmutable($apiDateFrom),
new \DateTimeImmutable($apiDateTo),
@@ -112,6 +235,36 @@ class AjaxCalendarController extends ActionController
]);
}
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;
if ($price !== null) {
$priceStr = (($item['type'] ?? null) === 'discount')
? $price . ' € (Angebot)'
: 'ab ' . $price . ' €';
} else {
$priceStr = 'Anfrage';
}
$minNights = (int)($item['minNights'] ?? 0);
return $minNights > 1
? $priceStr . ' · min. ' . $minNights . ' Nächte'
: $priceStr;
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -17,7 +17,8 @@ class ProductRepository extends AbstractRepository
$qb
->select(
'date.concept as conceptUid', 'date.concept_name as conceptName',
'date.concept_code as conceptCode', 'date.product as productUid', 'date.product_name as productName',
'date.concept_code as conceptCode', 'con.popup_text as conceptPopupText',
'date.product as productUid', 'date.product_name as productName',
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
'date.country as countryUid','date.country_name as countryName', 'date.country_code as countryCode',
'date.region as regionUid', 'date.region_name as regionName', 'date.region_feature as regionFeature',
@@ -30,6 +31,7 @@ class ProductRepository extends AbstractRepository
'date.available as available', 'date.pseudo_price as pseudoPrice'
)
->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->where($qb->expr()->in(
'date.product',
$qb->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY))
@@ -60,7 +62,7 @@ class ProductRepository extends AbstractRepository
->select(
'date.concept as conceptUid', 'date.concept_name as conceptName',
'date.concept_code as conceptCode', 'date.concept_sorting as conceptSorting',
'date.concept_popup_text as conceptPopupText',
'con.popup_text as conceptPopupText',
'date.product as productUid', 'date.product_name as productName',
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
'date.daytrip as daytrip', 'date.country as countryUid','date.country_name as countryName',
@@ -78,6 +80,7 @@ class ProductRepository extends AbstractRepository
'room.type as roomType'
)
->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
->where($qb->expr()->andX(
$qb->expr()->eq('date.product_searchable', 1),
@@ -125,31 +128,35 @@ class ProductRepository extends AbstractRepository
$qb
->select(
'product as productUid', 'product_name as productName', 'product_detail_page as productDetailPage',
'product_feature as productFeature', 'product_teaser as productTeaser', 'product_fact as productFact',
'product_slug as productSlug',
'hotel as hotelUid', 'hotel_fact as hotelFact', 'hotel_name as hotelName',
'hotel_category as hotelCategory', 'region_fact as regionFact', 'region_name as regionName',
'region_feature as regionFeature',
'country_name as countryName', 'country_code as countryCode',
'date_start as dateStart', 'date_end as dateEnd', 'uid as dateUid', 'min_price as minPrice',
'pseudo_price as pseudoPrice', 'nights as nights', 'available as available', 'season as season',
'skipass_included as skiPassIncluded', 'bus_included as busIncluded',
'earlybird', 'new', 'daytrip', 'hide_booking_button as hideBookingButton',
'low_contingent as lowContingent', 'concept as conceptUid', 'concept_code as conceptCode',
'concept_name as conceptName', 'concept_popup_text as conceptPopupText', 'board', 'new'
'date.product as productUid', 'date.product_name as productName',
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
'date.product_teaser as productTeaser', 'date.product_fact as productFact',
'date.product_slug as productSlug',
'date.hotel as hotelUid', 'date.hotel_fact as hotelFact', 'date.hotel_name as hotelName',
'date.hotel_category as hotelCategory', 'date.region_fact as regionFact',
'date.region_name as regionName', 'date.region_feature as regionFeature',
'date.country_name as countryName', 'date.country_code as countryCode',
'date.date_start as dateStart', 'date.date_end as dateEnd', 'date.uid as dateUid',
'date.min_price as minPrice', 'date.pseudo_price as pseudoPrice',
'date.nights as nights', 'date.available as available', 'date.season as season',
'date.skipass_included as skiPassIncluded', 'date.bus_included as busIncluded',
'date.earlybird', 'date.new', 'date.daytrip', 'date.hide_booking_button as hideBookingButton',
'date.low_contingent as lowContingent', 'date.concept as conceptUid',
'date.concept_code as conceptCode', 'date.concept_name as conceptName',
'con.popup_text as conceptPopupText', 'date.board'
)
->from('tx_epproducts_domain_model_date', 'date')
->where('date_start >= NOW()')
->groupBy('product')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->where('date.date_start >= NOW()')
->groupBy('date.product')
->addGroupBy('date.hotel')
->addGroupBy('date.date_start')
->addGroupBy('date.nights')
->having('available > 0')
->orderBy('important', 'DESC')
->addOrderBy('new', 'DESC')
->addOrderBy('concept_sorting')
->addOrderBy('date_start')
->having('date.available > 0')
->orderBy('date.important', 'DESC')
->addOrderBy('date.new', 'DESC')
->addOrderBy('date.concept_sorting')
->addOrderBy('date.date_start')
;
if ($filterSettings['productsPid']) {
@@ -165,10 +172,10 @@ class ProductRepository extends AbstractRepository
}
if (empty($filterSettings['productUid'])) {
$qb->andWhere($qb->expr()->eq('product_searchable', 1));
$qb->andWhere($qb->expr()->eq('date.product_searchable', 1));
} else {
$qb->andWhere($qb->expr()->eq(
'product',
'date.product',
$qb->createNamedParameter($filterSettings['productUid'], Connection::PARAM_INT))
);
}
@@ -2,7 +2,7 @@
namespace EP\EpProducts\Middleware;
use EP\EpProducts\Service\HotelImageService;
use EP\EpProducts\Service\MyEpImageService;
use EP\EpProducts\Service\ProductImageService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -167,7 +167,7 @@ class ProductApiMiddleware implements MiddlewareInterface
return [];
}
$images = GeneralUtility::makeInstance(HotelImageService::class)
$images = GeneralUtility::makeInstance(MyEpImageService::class)
->getImages((int) $hotel['uid']);
return [
@@ -111,6 +111,33 @@ class ApiClient implements LoggerAwareInterface
return $this->requestJson('GET', 'pickups-planning/' . $travelCode);
}
/**
* @throws ApiException
*/
public function getPriceConfigData(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
{
return $this->requestJson('GET', 'contingents/calendar', [
'query' => [
'hotelCode' => $hotelCode,
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
],
]);
}
/**
* @throws ApiException
*/
public function getPriceTableData(string $hotelCode, string $year): array
{
return $this->requestJson('GET', 'contingents/prices', [
'query' => [
'hotelCode' => $hotelCode,
'year' => $year,
],
]);
}
/**
* @throws ApiException
*/
@@ -171,9 +171,9 @@ class FilterService implements SingletonInterface
$dateMin = $item['minDateStart'];
}
if (null === $dateMax || $item['maxDateEnd'] > $dateMax) {
$dateMax = $row['itemMaxDateEnd'];
$dateMax = $item['maxDateEnd'];
}
if ((bool) $row['busAvailable'] === true) {
if ((bool) $item['busAvailable'] === true) {
$busAvailable = true;
}
foreach ($item['nights'] as $nightsOption) {
@@ -182,36 +182,47 @@ class FilterService implements SingletonInterface
$nights[] = $duration;
}
}
if (!array_key_exists($item['country']['uid'], $destinations)) {
$destinations[$item['country']['uid']] = [
$itemDestinations = $item['filterMetadata']['destinations'] ?? [[
'country' => $item['country'],
'region' => $item['region'],
]];
foreach ($itemDestinations as $itemDestination) {
$country = $itemDestination['country'];
$region = $itemDestination['region'];
if (!array_key_exists($country['uid'], $destinations)) {
$destinations[$country['uid']] = [
'country' => [
'uid' => $item['country']['uid'],
'label' => $item['country']['name'],
'code' => $item['country']['code'],
'uid' => $country['uid'],
'label' => $country['name'],
'code' => $country['code'],
],
'regions' => [],
];
];
}
if (!array_key_exists($region['uid'], $destinations[$country['uid']]['regions'])) {
$destinations[$country['uid']]['regions'][$region['uid']] = [
'uid' => $region['uid'],
'label' => $region['name'],
];
}
}
if (!array_key_exists($item['region']['uid'], $destinations[$item['country']['uid']]['regions'])) {
$destinations[$item['country']['uid']]['regions'][$item['region']['uid']] = [
'uid' => $item['region']['uid'],
'label' => $item['region']['name'],
];
}
if (!\in_array($item['hotel']['type'], $hotelTypes, false)) {
$hotelTypes[] = $item['hotel']['type'];
foreach ($item['filterMetadata']['hotelTypes'] ?? [$item['hotel']['type']] as $hotelType) {
if (!\in_array($hotelType, $hotelTypes, false)) {
$hotelTypes[] = $hotelType;
}
}
foreach ($item['rooms'] as $roomType) {
if (!\in_array($roomType, $roomTypes, false)) {
$roomTypes[] = $roomType;
}
}
$boardType = [
foreach ($item['filterMetadata']['boardTypes'] ?? [[
'uid' => $item['boardUid'],
'label' => $item['board'],
];
if (!\in_array($boardType, $boardTypes, false)) {
$boardTypes[] = $boardType;
]] as $boardType) {
if (!\in_array($boardType, $boardTypes, false)) {
$boardTypes[] = $boardType;
}
}
foreach (FilterOptions::$priceRangeOptions as $index => $priceRange) {
if (array_key_exists($index, $priceRanges)) {
@@ -0,0 +1,58 @@
<?php
namespace EP\EpProducts\Service;
/***************************************************************
*
* Copyright notice
*
* (c) 2018 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!
***************************************************************/
class MyEpImageService extends AbstractImageService
{
/**
* @var string
*/
protected $table = 'tx_epproducts_domain_model_hotel';
/**
* @var string
*/
protected $field = 'images';
/**
* @var array
*/
protected $imageSizes = [
's' => [
'width' => '480',
'height' => '360c-100',
],
'm' => [
'width' => '768',
'height' => '576c-100',
],
'l' => [
'width' => '1280',
'height' => '960c-100',
]
];
}
@@ -49,14 +49,25 @@ class SearchResultService implements SingletonInterface
*/
protected $hotelImageService;
/**
* @var ProductImageService
*/
protected $productImageService;
/**
* @param ProductRepository $productRepository
* @param HotelImageService $hotelImageService
* @param ProductImageService $productImageService
*/
public function __construct(ProductRepository $productRepository, HotelImageService $hotelImageService)
public function __construct(
ProductRepository $productRepository,
HotelImageService $hotelImageService,
ProductImageService $productImageService
)
{
$this->productRepository = $productRepository;
$this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService;
}
public function getNormalizedSearchParams(array $searchParams = []): array
@@ -80,10 +91,10 @@ class SearchResultService implements SingletonInterface
{
$searchResultData = $this->productRepository->getSearchResult($filterSettings, $excludedConceptUids);
return $this->preprocess($searchResultData);
return $this->preprocess($searchResultData, true);
}
public function preprocess(array $searchResultData): SearchResult
public function preprocess(array $searchResultData, bool $aggregateEvents = false): SearchResult
{
$data = [];
$total = 0;
@@ -105,8 +116,13 @@ class SearchResultService implements SingletonInterface
$items =& $data[$row['conceptUid']]['items'];
// Use compound key, hotel is not unique
$key = $row['hotelUid'] . ':' . $row['productUid'];
$isAggregatedEvent = $aggregateEvents && 'evt' === $row['conceptCode'];
// Events are product-level search results. Other results (including
// watchlist items) remain tied to a particular hotel.
$key = $isAggregatedEvent
? (string) $row['productUid']
: $row['hotelUid'] . ':' . $row['productUid'];
$hotelCategory = Hotel::$categoryLabels[$row['hotelCategory']] ?? null;
@@ -122,6 +138,7 @@ class SearchResultService implements SingletonInterface
'season' => $row['season'],
'hideBookingButton' => $row['hideBookingButton'],
'busAvailable' => $row['busAvailable'],
'available' => $row['available'],
'lowContingent' => $row['lowContingent'],
'servicesIncluded' => unserialize($row['servicesIncluded'], ['allowed_classes' => false]),
'hotel' => [
@@ -151,21 +168,55 @@ class SearchResultService implements SingletonInterface
'subline' => $row['productSubline'],
'feature' => $row['productFeature'],
'fact' => $row['productFact'],
'images' => $isAggregatedEvent
? $this->productImageService->getImages($row['productUid'])
: [],
],
'board' => $row['board'],
'boardUid' => $row['boardUid'],
'dates' => [],
'nights' => [],
'teaserNights' => [],
'rooms' => [],
'new' => $row['new'],
'skiPassIncluded' => $row['skiPassIncluded'],
'filterMetadata' => [
'destinations' => [],
'hotelTypes' => [],
'boardTypes' => [],
],
];
$total++;
}
$item =& $items[$key];
if (!\in_array($row['roomType'], $item['rooms'], false)) {
$destination = [
'country' => [
'uid' => $row['countryUid'],
'code' => $row['countryCode'],
'name' => $row['countryName'],
],
'region' => [
'uid' => $row['regionUid'],
'name' => $row['regionName'],
'fact' => $row['regionFact'],
'feature' => $row['regionFeature'],
],
];
if (!\in_array($destination, $item['filterMetadata']['destinations'], false)) {
$item['filterMetadata']['destinations'][] = $destination;
}
$hotelType = $row['hotelType'] ?? $item['hotel']['type'];
if (!\in_array($hotelType, $item['filterMetadata']['hotelTypes'], false)) {
$item['filterMetadata']['hotelTypes'][] = $hotelType;
}
$boardType = ['uid' => $row['boardUid'] ?? null, 'label' => $row['board']];
if (!\in_array($boardType, $item['filterMetadata']['boardTypes'], false)) {
$item['filterMetadata']['boardTypes'][] = $boardType;
}
if (isset($row['roomType']) && !\in_array($row['roomType'], $item['rooms'], false)) {
$item['rooms'][] = $row['roomType'];
}
@@ -193,8 +244,25 @@ class SearchResultService implements SingletonInterface
if ($item['minPrice'] === null
|| $item['minPrice'] > $row['minPrice']) {
$item['minPrice'] = $row['minPrice'];
// Teaser-specific offer data must describe the cheapest row.
$item['teaserNights'] = [$row['nights']];
$item['board'] = $row['board'];
$item['boardUid'] = $row['boardUid'];
$item['daytrip'] = $row['daytrip'];
$item['season'] = $row['season'];
$item['hideBookingButton'] = $row['hideBookingButton'];
$item['servicesIncluded'] = unserialize($row['servicesIncluded'], ['allowed_classes' => false]);
$item['new'] = $row['new'];
$item['skiPassIncluded'] = $row['skiPassIncluded'];
}
// Availability indicators describe the consolidated set, not only
// the row that supplies the teaser price.
$item['earlybird'] = (bool) $item['earlybird'] || (bool) $row['earlybird'];
$item['busAvailable'] = (bool) $item['busAvailable'] || (bool) $row['busAvailable'];
$item['lowContingent'] = (bool) $item['lowContingent'] || (bool) $row['lowContingent'];
$item['available'] = (bool) $item['available'] || (bool) $row['available'];
}
// Sort products by minprice within concept sections except for 'event' concept
@@ -28,6 +28,10 @@ services:
arguments:
$cache: '@cache.ep_cache'
EP\EpProducts\Service\MyEpImageService:
arguments:
$cache: '@cache.ep_cache'
EP\EpProducts\Controller\AjaxCalendarController:
arguments:
$cache: '@cache.ep_cache'
@@ -48,6 +48,8 @@ plugin.tx_epproducts {
groupsPriceToEmail = [email protected]
# cat=epproducts/130/120; type=string; label=Email recipient for groups price inquiries
groupsPriceToName = EP Reisen
# cat=epproducts/130/130; type=string; label=Email recipient for groups price inquiries
groupsBookingUrl = https://myep.ddev.site/groups/booking
# cat=epproducts/140/100; type=string; label=Room types for pricetables (CSV)
priceTableRoomTypes = 1,2,3,4,5,6,7,8,9,10
# cat=epproducts/140/110; type=string; label=Ignore following IPs in searchlog
@@ -54,6 +54,7 @@ plugin.tx_epproducts {
groupsPriceToEmail = {$plugin.tx_epproducts.settings.groupsPriceToEmail}
groupsPriceToName = {$plugin.tx_epproducts.settings.groupsPriceToName}
groupInquiryFormConceptUids = {$plugin.tx_epproducts.settings.groupInquiryFormConceptUids}
groupsBookingUrl = {$plugin.tx_epproducts.settings.groupsBookingUrl}
logoImage = {$plugin.tx_eptheme.settings.logoImage}
themekey = {$plugin.tx_eptheme.settings.themekey}
globalConceptCode = {$plugin.tx_eptheme.settings.globalConceptCode}
@@ -173,6 +174,9 @@ tx_epproducts_ajax_json {
}
AjaxCalendar {
1 = range
2 = priceConfig
3 = priceTable
4 = priceCalendar
}
AjaxContingent {
1 = list
@@ -276,7 +276,7 @@ $boot = function () {
'AjaxDate' => 'dates',
'AjaxContingent' => 'list,rooms',
'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms',
'AjaxCalendar' => 'range,contingents,availableRooms',
'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable',
'AjaxWatchlist' => 'list',
'AjaxGroupsPrice' => 'processForm',
'AjaxTeaserPopup' => 'concept',
@@ -287,7 +287,7 @@ $boot = function () {
'AjaxDate' => 'dates',
'AjaxContingent' => 'list,rooms',
'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms',
'AjaxCalendar' => 'range,contingents,availableRooms',
'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable',
'AjaxWatchlist' => 'list',
'AjaxGroupsPrice' => 'processForm',
'AjaxTeaserPopup' => 'concept',
@@ -0,0 +1,178 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['mount', 'error', 'message', 'button', 'resetButton', 'day', 'month', 'prevButton', 'nextButton']
static values = {
hotelCode: String,
groupsBookingUrl: String,
}
connect() {
this.startDate = null
this.endDate = null
this.currentIndex = 0
// mountTarget carries hx-get, so HTMX fires its events there; listening here
// scopes to this calendar instance and avoids catching events from other HTMX elements.
this.mountTarget.addEventListener('htmx:afterSwap', () => this.showMonths(0))
this.mountTarget.addEventListener('htmx:responseError', () => this.showError())
}
showMonths(startIndex) {
this.currentIndex = startIndex
this.monthTargets.forEach((m, i) => {
m.classList.toggle('hidden', i < startIndex || i >= startIndex + 2)
})
if (this.hasPrevButtonTarget) this.prevButtonTarget.disabled = startIndex === 0
if (this.hasNextButtonTarget) this.nextButtonTarget.disabled = startIndex >= this.monthTargets.length - 2
}
prevMonths() {
this.showMonths(Math.max(0, this.currentIndex - 1))
}
nextMonths() {
this.showMonths(Math.min(this.currentIndex + 1, this.monthTargets.length - 2))
}
selectDate(event) {
const btn = event.currentTarget
// cooltipz shows on :focus as well as :hover; blurring dismisses it after a click.
btn.blur()
const date = btn.dataset.date
const status = btn.dataset.status
// All day buttons carry data-action for template simplicity; non-selectable ones are filtered here.
if (!status || status === 'blocked') return
if (this.startDate && this.endDate) {
this.resetSelection()
}
if (!this.startDate) {
// checkout-only days allow departure but not arrival
if (status === 'checkout-only') {
this.showMessage('Bitte wähle einen Anreisetag.')
return
}
this.startDate = date
this.endDate = null
this.updateDisplay()
this.showMessage('Bitte wähle das Abreisedatum.')
this.disableButton()
return
}
if (date === this.startDate) {
this.resetSelection()
return
}
// Normalize so from < to regardless of which date the user clicked first.
const [from, to] = date < this.startDate ? [date, this.startDate] : [this.startDate, date]
if (this.hasBlockedInRange(from, to)) {
this.resetSelection()
this.showMessage('Dieser Zeitraum enthält nicht verfügbare Daten.')
return
}
const startBtn = this.dayTargets.find(b => b.dataset.date === from)
const minNights = parseInt(startBtn?.dataset.minNights) || 0
const nights = (new Date(to) - new Date(from)) / 86400000 // ms → days
// Short-stay warning is advisory only; the booking link is still enabled below.
if (minNights > 0 && nights < minNights) {
this.showMessage(`Weniger als ${minNights} Nächte nur auf Anfrage.`)
} else {
this.hideMessage()
}
this.startDate = from
this.endDate = to
this.updateDisplay()
this.enableButton(from, to)
this.dispatch('range-select', { detail: { dateFrom: from, dateTo: to } })
}
hasBlockedInRange(from, to) {
for (const btn of this.dayTargets) {
const d = btn.dataset.date
// End date is exclusive: guests may check out of a blocked day, so it is not a hard blocker.
if (d >= from && d < to && btn.dataset.status === 'blocked') return true
}
return false
}
updateDisplay() {
for (const btn of this.dayTargets) {
const d = btn.dataset.date
btn.classList.remove('pc-day--pending-start', 'pc-day--selected-start', 'pc-day--selected-end', 'pc-day--in-range')
if (!this.startDate) continue
if (d === this.startDate) {
btn.classList.add(this.endDate ? 'pc-day--selected-start' : 'pc-day--pending-start')
} else if (this.endDate) {
if (d === this.endDate) btn.classList.add('pc-day--selected-end')
else if (d > this.startDate && d < this.endDate) btn.classList.add('pc-day--in-range')
}
}
}
resetSelection() {
this.startDate = null
this.endDate = null
this.updateDisplay()
this.hideMessage()
this.disableButton()
}
reset() {
this.resetSelection()
}
enableButton(dateFrom, dateTo) {
if (!this.hasButtonTarget) return
const params = new URLSearchParams({
hotel_code: this.hotelCodeValue,
date_from: dateFrom,
date_to: dateTo,
})
this.buttonTarget.href = `${this.groupsBookingUrlValue}?${params}`
this.buttonTarget.textContent = `${this.formatDate(dateFrom)}–${this.formatDate(dateTo)} Anfragen / Buchen`
this.buttonTarget.classList.remove('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
}
disableButton() {
if (!this.hasButtonTarget) return
this.buttonTarget.removeAttribute('href')
this.buttonTarget.textContent = 'Anfragen / Buchen'
this.buttonTarget.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.add('hidden')
}
formatDate(iso) {
const [y, m, d] = iso.split('-')
return `${d}.${m}.${y.slice(2)}`
}
showMessage(text) {
if (this.hasMessageTarget) {
this.messageTarget.textContent = text
}
}
hideMessage() {
if (this.hasMessageTarget) {
this.messageTarget.textContent = ''
}
}
showError() {
if (this.hasErrorTarget) {
this.errorTarget.classList.remove('hidden')
}
}
}
@@ -1,5 +1,4 @@
import { Controller } from '@hotwired/stimulus'
import { useDispatch } from 'stimulus-use'
export default class extends Controller {
@@ -9,10 +8,6 @@ export default class extends Controller {
static values = { activeTab: String }
initialize() {
useDispatch(this)
}
connect() {
this.activeTabValue = this.tabTargets[0].dataset.tab
}
@@ -20,7 +15,8 @@ export default class extends Controller {
select(e) {
let selectedTab = e.currentTarget.dataset.tab
this.activeTabValue = selectedTab
this.dispatch('selected', selectedTab)
this.dispatch('selected', { detail: selectedTab })
this.dispatch('tab-' + selectedTab)
}
activeTabValueChanged() {
@@ -44,6 +44,47 @@
}
}
// Price calendar (hand-rolled)
.pc-loading-indicator {
display: none;
&.htmx-request {
display: flex;
}
}
.pc-day {
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer;
// Status colors — [data-status] attribute adds specificity (0,2,0)
&[data-status="ok"] {
@apply bg-emerald-500;
}
&[data-status="blocked-to-ok"] {
background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%);
}
&[data-status="checkout-only"] {
background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%);
}
&[data-status="blocked"] {
@apply bg-gray-400 cursor-default;
}
&[data-status=""], &:not([data-status]) {
@apply text-gray-400 cursor-default;
}
// Compound selector gives (0,2,0) specificity, matching [data-status] rules; source order wins.
&.pc-day--pending-start,
&.pc-day--selected-start,
&.pc-day--selected-end {
@apply bg-none outline-none bg-ep-secondary text-gray-700;
}
&.pc-day--in-range {
@apply bg-none bg-ep-secondary text-gray-700 opacity-55;
}
}
// Maps
@import "~leaflet/dist/leaflet.css";
@@ -0,0 +1,42 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers">
<div data-controller="price-calendar"
data-price-calendar-hotel-code-value="{product.calendarHotel.code}"
data-price-calendar-groups-booking-url-value="{settings.groupsBookingUrl}"
class="relative p-4 bg-zinc-50">
<div class="pc-loading-indicator items-center space-x-4 text-sm p-4"
id="pc-loading-{product.calendarHotel.uid}">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
<span>Preiskalender wird geladen …</span>
</div>
<div data-price-calendar-target="mount"
hx-get="{ep:uri.ajax(action: 'priceCalendar', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel}', format: 'html')}"
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}"
hx-indicator="#pc-loading-{product.calendarHotel.uid}">
</div>
<div class="flex justify-between items-center">
<div class="h-8 text-sm" data-price-calendar-target="message"></div>
<div class="h-8 text-sm text-right">
<button type="button"
class="hidden underline"
data-price-calendar-target="resetButton"
data-action="click->price-calendar#reset">
Auswahl zurücksetzen
</button>
</div>
</div>
<a class="button bg-button w-full mt-2 opacity-50 cursor-not-allowed pointer-events-none"
data-price-calendar-target="button"
target="_blank"
rel="noopener noreferrer">
Anfragen / Buchen
</a>
<div class="hidden p-4 text-sm text-red-600" data-price-calendar-target="error">
Der Kalender konnte aktuell nicht geladen werden.
</div>
</div>
</html>
@@ -38,6 +38,8 @@
</f:if>
<span class="product-teaser__label-concept">{concept.name}</span>
</div>
<f:if condition="{hideWatchlist}">
<f:else>
<button type="button"
data-controller="watchlist-toggle"
data-watchlist-toggle-key-value="{hotel.uid}:{product.uid}"
@@ -47,6 +49,8 @@
aria-label="auf die Merkliste setzen">
<svg class="product-teaser__watchlist-icon" data-watchlist-toggle-target="icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path></svg>
</button>
</f:else>
</f:if>
</div>
<f:if condition="{new}">
<div class="absolute top-12 left-2 text-white font-bold text-sm leading-none p-1 bg-badge-gradient">
@@ -64,24 +64,43 @@
</button>
</div>
<div class="grid grid-cols-2 xl:grid-cols-3 gap-4 md:gap-8"
data-teasergrid-target="container">
data-teasergrid-target="container">
<f:for each="{section.items}" as="item">
<f:if condition="{section.concept.code} == 'evt'">
<f:then>
<f:variable name="searchProductImage" value="{item.product.images.original.0}"/>
<f:variable name="searchForceHotelUid" value="0"/>
<f:variable name="searchShowHotelDetails" value="0"/>
<f:variable name="searchHideWatchlist" value="1"/>
<f:variable name="searchDateFrom" value=""/>
<f:variable name="searchDateTo" value=""/>
</f:then>
<f:else>
<f:variable name="searchProductImage" value="{item.hotel.images.original.0}"/>
<f:variable name="searchForceHotelUid" value="1"/>
<f:variable name="searchShowHotelDetails" value="1"/>
<f:variable name="searchHideWatchlist" value="0"/>
<f:variable name="searchDateFrom" value="{item.minDateStart}"/>
<f:variable name="searchDateTo" value="{item.maxDateEnd}"/>
</f:else>
</f:if>
<f:render partial="Product/Teaser" section="Teaser" arguments="{
concept: section.concept,
hotel: item.hotel,
product: item.product,
country: item.country,
region: item.region,
productImage: item.hotel.images.original.0,
nights: item.nights,
productImage: searchProductImage,
nights: item.teaserNights,
board: item.board,
minPrice: item.minPrice,
daytrip: item.daytrip,
season: item.season,
dateFrom: item.minDateStart,
dateTo: item.maxDateEnd,
forceHotelUid: 1,
showHotelDetails: 1,
dateFrom: searchDateFrom,
dateTo: searchDateTo,
forceHotelUid: searchForceHotelUid,
showHotelDetails: searchShowHotelDetails,
hideWatchlist: searchHideWatchlist,
new: item.new,
skiPassIncluded: item.skiPassIncluded,
referringPageUri: referringPageUri
@@ -0,0 +1,70 @@
<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: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; Vorherige</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">Nächste &#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 @@
{priceConfig -> f:format.json() ->f:format.raw()}
@@ -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="{priceTable.success} && {priceTable.data -> f:count()}">
<div class="w-full overflow-x-scroll 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>
</f:if>
</html>
@@ -126,11 +126,29 @@
<div class="mb-4{f:if(condition: settings.hotelOnTop, then: ' hidden')}"
data-tabs-target="tab"
data-tab="dates">
<f:if condition="{hotel.groupsPriceConfigs} && {nonBookableInfo}">
<div class="alert alert-info mb-4" data-rte-content>
{nonBookableInfo.text -> f:format.html()}
</div>
</f:if>
<f:if condition="{product.calendarHotel}">
<f:render partial="Product/PriceCalendar.html" arguments="{_all}"/>
<f:variable name="currentYear" value="{f:format.date(date: 'now', format: 'Y')}"/>
<div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: currentYear}', format: 'html')}"
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}">
<f:image src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt="Loading" title="Loading"/>
</div>
<f:variable name="nextYear" value="{f:format.date(date: '+1 year', format: 'Y')}"/>
<div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: nextYear}', format: 'html')}"
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}">
<f:image src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt="Loading" title="Loading"/>
</div>
</f:if>
<f:if condition="{isProductWithExcludedConcept}">
<f:if condition="{product.region.season} == 'w'">
<div class="bg-ep-primary-light text-white p-4">
@@ -146,6 +164,7 @@
</div>
</f:if>
</f:if>
<f:if condition="{product.daytrip}">
<f:then>
<f:render section="DaytripDateSelect" arguments="{_all}"/>
@@ -164,10 +183,12 @@
<div class="hidden" data-product-details-target="priceTableContainer"></div>
</f:else>
</f:if>
<div class="lg:hidden">
<f:render section="SidebarGallery" arguments="{_all}"/>
<f:render section="SidebarButtons" arguments="{_all}"/>
</div>
<f:render partial="Product/Details" arguments="{_all}"/>
</div>
</f:section>