Compare commits

...
5 Commits
Author SHA1 Message Date
Björn Fromme 65edc184e6 wip: groups bookings calendar 2026-07-16 08:47:37 +02:00
Björn Fromme 83b3cbea4d feat: extended api endpoint for hotel data consumed by myep 2026-07-16 08:41:11 +02:00
Björn Fromme 11fda126a8 feat: hide 'show all' button for less than 3 teasers
addresses #869e545n0
2026-07-15 15:22:23 +02:00
Björn Fromme 1fcca90969 feat: disable included services list below dates table
addresses #869e50jt1
2026-07-15 12:41:21 +02:00
Björn Fromme 2f58b02928 feat: additional hotel properties
addresses #86965w970
2026-07-15 10:52:08 +02:00
27 changed files with 1184 additions and 98 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>>
*/
@@ -285,6 +285,56 @@ class Hotel extends AbstractEntity implements TeaserInterface
*/
protected $mainSeasonTo;
/**
* @var int
*/
protected $bedsCount = 0;
/**
* @var int
*/
protected $parkingCount = 0;
/**
* @var string
*/
protected $altitude = '';
/**
* @var string
*/
protected $shower = '';
/**
* @var bool
*/
protected $partyroom = false;
/**
* @var bool
*/
protected $gamesroom = false;
/**
* @var bool
*/
protected $sauna = false;
/**
* @var bool
*/
protected $wifi = false;
/**
* @var string
*/
protected $distanceBus = '';
/**
* @var string
*/
protected $distanceLift = '';
public function __construct()
{
$this->initStorageObjects();
@@ -1103,4 +1153,104 @@ class Hotel extends AbstractEntity implements TeaserInterface
{
$this->mainSeasonTo = $mainSeasonTo;
}
public function getBedsCount()
{
return $this->bedsCount;
}
public function setBedsCount($bedsCount)
{
$this->bedsCount = $bedsCount;
}
public function getParkingCount()
{
return $this->parkingCount;
}
public function setParkingCount($parkingCount)
{
$this->parkingCount = $parkingCount;
}
public function getAltitude()
{
return $this->altitude;
}
public function setAltitude($altitude)
{
$this->altitude = $altitude;
}
public function getShower()
{
return $this->shower;
}
public function setShower($shower)
{
$this->shower = $shower;
}
public function isPartyroom()
{
return $this->partyroom;
}
public function setPartyroom($partyroom)
{
$this->partyroom = $partyroom;
}
public function isGamesroom()
{
return $this->gamesroom;
}
public function setGamesroom($gamesroom)
{
$this->gamesroom = $gamesroom;
}
public function isSauna()
{
return $this->sauna;
}
public function setSauna($sauna)
{
$this->sauna = $sauna;
}
public function isWifi()
{
return $this->wifi;
}
public function setWifi($wifi)
{
$this->wifi = $wifi;
}
public function getDistanceBus()
{
return $this->distanceBus;
}
public function setDistanceBus($distanceBus)
{
$this->distanceBus = $distanceBus;
}
public function getDistanceLift()
{
return $this->distanceLift;
}
public function setDistanceLift($distanceLift)
{
$this->distanceLift = $distanceLift;
}
}
@@ -2,18 +2,58 @@
namespace EP\EpProducts\Middleware;
use EP\EpProducts\Service\HotelImageService;
use EP\EpProducts\Service\ProductImageService;
use EP\EpProducts\Service\MyEpImageService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
class ProductApiMiddleware implements MiddlewareInterface
class ApiMiddleware implements MiddlewareInterface
{
/**
* @var MyEpImageService
*/
private $hotelImageService;
/**
* @var MyEpImageService
*/
private $productImageService;
/**
* @var MyEpImageService
*/
private $regionImageService;
/**
* @var FileRepository
*/
private $fileRepository;
/**
* @var ImageService
*/
private $imageService;
public function __construct(
MyEpImageService $hotelImageService,
MyEpImageService $productImageService,
MyEpImageService $regionImageService,
FileRepository $fileRepository,
ImageService $imageService
) {
$this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService;
$this->regionImageService = $regionImageService;
$this->fileRepository = $fileRepository;
$this->imageService = $imageService;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
@@ -91,15 +131,15 @@ class ProductApiMiddleware implements MiddlewareInterface
$data = $queryBuilder
->select(
'product.uid as productUid',
'product.name as productName',
'product.code as productCode',
'product.region as regionUid',
'country.name as countryName',
'region.name as regionName',
'city.name as cityName',
)
->from('tx_epproducts_domain_model_product', 'product')
->leftJoin('product', 'tx_epproducts_domain_model_country', 'country', 'product.country = country.uid')
->leftJoin('product', 'tx_epproducts_domain_model_region', 'region', 'product.region = region.uid')
->leftJoin('product', 'tx_epproducts_domain_model_city', 'city', 'product.city = city.uid')
->leftJoin('product', 'tx_epproducts_domain_model_matchcode', 'matchcode', 'product.uid = matchcode.entity AND matchcode.entity_type="hotel"')
->where($queryBuilder->expr()->eq('product.code', $queryBuilder->createNamedParameter($productCode)))
@@ -113,8 +153,7 @@ class ProductApiMiddleware implements MiddlewareInterface
return null;
}
$images = GeneralUtility::makeInstance(ProductImageService::class)
->getImages((int) $data['uid']);
$images = $this->serializeImages($this->productImageService->getImages((int) $data['productUid']));
return [
'product' => [
@@ -125,9 +164,7 @@ class ProductApiMiddleware implements MiddlewareInterface
'country' => [
'name' => $data['countryName'],
],
'region' => [
'name' => $data['regionName'],
],
'region' => $this->fetchRegionData((int) $data['regionUid']),
'city' => [
'name' => $data['cityName'],
],
@@ -149,6 +186,17 @@ class ProductApiMiddleware implements MiddlewareInterface
'hotel.room_types',
'hotel.additional_information',
'hotel.address',
'hotel.beds_count',
'hotel.parking_count',
'hotel.altitude',
'hotel.shower',
'hotel.partyroom',
'hotel.gamesroom',
'hotel.sauna',
'hotel.wifi',
'hotel.distance_bus',
'hotel.distance_lift',
'hotel.region',
)
->from('tx_epproducts_domain_model_hotel', 'hotel')
->leftJoin('hotel', 'tx_epproducts_domain_model_matchcode', 'alias', 'hotel.uid = alias.entity AND alias.entity_type="hotel"')
@@ -167,8 +215,7 @@ class ProductApiMiddleware implements MiddlewareInterface
return [];
}
$images = GeneralUtility::makeInstance(HotelImageService::class)
->getImages((int) $hotel['uid']);
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
return [
'name' => $hotel['name'],
@@ -179,6 +226,101 @@ class ProductApiMiddleware implements MiddlewareInterface
'additionalInformation' => $hotel['additional_information'],
'address' => $hotel['address'],
'images' => $images,
'icons' => [
'bedsCount' => (int) $hotel['beds_count'],
'parkingCount' => (int) $hotel['parking_count'],
'altitude' => $hotel['altitude'],
'shower' => $hotel['shower'],
'partyroom' => (bool) $hotel['partyroom'],
'gamesroom' => (bool) $hotel['gamesroom'],
'sauna' => (bool) $hotel['sauna'],
'wifi' => (bool) $hotel['wifi'],
'distanceBus' => $hotel['distance_bus'],
'distanceLift' => $hotel['distance_lift'],
],
'region' => $this->fetchRegionData((int) $hotel['region']),
];
}
private function fetchRegionData(int $regionUid): array
{
if (0 === $regionUid) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_epproducts_domain_model_region');
$region = $queryBuilder
->select(
'region.uid',
'region.name',
'region.latitude',
'region.longitude',
'region.ski_area',
'region.ski_area_extended',
'region.description',
'region.news',
'region.length',
'region.height',
'region.lifts',
'webcam.url as webcamUrl',
)
->from('tx_epproducts_domain_model_region', 'region')
->leftJoin('region', 'tx_epproducts_domain_model_webcam', 'webcam', 'region.webcam = webcam.uid')
->where($queryBuilder->expr()->eq('region.uid', $queryBuilder->createNamedParameter($regionUid, \PDO::PARAM_INT)))
->setMaxResults(1)
->execute()
->fetchAssociative()
;
if (false === $region) {
return [];
}
$images = $this->serializeImages($this->regionImageService->getImages((int) $region['uid']));
$regionMaps = $this->serializeFileReferences(
$this->fileRepository->findByRelation('tx_epproducts_domain_model_region', 'region_maps', (int) $region['uid'])
);
return [
'name' => $region['name'],
'latitude' => $region['latitude'],
'longitude' => $region['longitude'],
'webcam' => $region['webcamUrl'],
'ski_area' => $region['ski_area'],
'ski_area_extended' => $region['ski_area_extended'],
'description' => $region['description'],
'news' => $region['news'],
'length' => $region['length'],
'altitude' => $region['height'],
'lifts' => $region['lifts'],
'images' => $images,
'region_maps' => $regionMaps,
];
}
/**
* The 'original' entry of AbstractImageService::getImages() holds raw FileReference objects,
* which are needed by Fluid templates elsewhere but serialize to empty objects in JSON responses.
* We replace it with the same {url, alt, title} shape used for 'region_maps'.
*/
private function serializeImages(array $images): array
{
return [
'original' => $this->serializeFileReferences($images['original']),
'resized' => $images['resized'],
];
}
private function serializeFileReferences(array $files): array
{
return array_map(function ($file) {
return [
'url' => $this->imageService->getImageUri($file, true),
'alt' => $file->getProperty('alternative'),
'title' => $file->getProperty('title'),
];
}, $files);
}
}
@@ -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
*/
@@ -0,0 +1,63 @@
<?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!
***************************************************************/
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Extbase\Service\ImageService;
class MyEpImageService extends AbstractImageService
{
/**
* @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',
]
];
public function __construct(FileRepository $fileRepository, ImageService $imageService, FrontendInterface $cache, string $table = 'tx_epproducts_domain_model_hotel')
{
parent::__construct($fileRepository, $imageService, $cache);
$this->table = $table;
}
}
@@ -12,7 +12,7 @@ return [
],
],
'ep/productapi' => [
'target' => \EP\EpProducts\Middleware\ProductApiMiddleware::class,
'target' => \EP\EpProducts\Middleware\ApiMiddleware::class,
'before' => [
'typo3/cms-frontend/page-resolver',
],
@@ -28,6 +28,38 @@ services:
arguments:
$cache: '@cache.ep_cache'
EP\EpProducts\Service\MyEpImageService:
arguments:
$cache: '@cache.ep_cache'
EP\EpProducts\Controller\AjaxCalendarController:
arguments:
$cache: '@cache.ep_cache'
EP\EpProducts\Service\MyEpImageService:
arguments:
$cache: '@cache.ep_cache'
ep_products.image_service.hotel:
class: EP\EpProducts\Service\MyEpImageService
arguments:
$cache: '@cache.ep_cache'
$table: 'tx_epproducts_domain_model_hotel'
ep_products.image_service.product:
class: EP\EpProducts\Service\MyEpImageService
arguments:
$cache: '@cache.ep_cache'
$table: 'tx_epproducts_domain_model_product'
ep_products.image_service.region:
class: EP\EpProducts\Service\MyEpImageService
arguments:
$cache: '@cache.ep_cache'
$table: 'tx_epproducts_domain_model_region'
EP\EpProducts\Middleware\ApiMiddleware:
arguments:
$hotelImageService: '@ep_products.image_service.hotel'
$productImageService: '@ep_products.image_service.product'
$regionImageService: '@ep_products.image_service.region'
@@ -31,7 +31,8 @@ return [
--div--;Verknüpfungen, facts, teamer, additional_codes, mappings,
--div--;Bilder/Dateien, teaser_images, header_images, images, reseller_images,
--div--;Gruppenhaus Preise, --palette--;;season,--palette--;;others, groups_price_configs,
groups_price_boards, groups_price_options',
groups_price_boards, groups_price_options,
--div--;Gruppenhaus Icons, --palette--;;icons',
],
],
'palettes' => [
@@ -42,7 +43,8 @@ return [
'name' => ['showitem' => 'name,short_name,--linebreak--,header_title,header_subtitle,--linebreak--,headline'],
'categories' => ['showitem' => 'type,category'],
'season' => ['showitem' => 'main_season_from, main_season_to'],
'others' => ['showitem' => 'season,groups_price_tax_label, groups_price_adolescents_age']
'others' => ['showitem' => 'season,groups_price_tax_label, groups_price_adolescents_age'],
'icons' => ['showitem' => 'beds_count,parking_count,altitude,shower,partyroom,gamesroom,sauna,wifi,distance_bus,distance_lift'],
],
'columns' => [
@@ -758,5 +760,87 @@ return [
'renderType' => 'inputDateTime',
],
],
'beds_count' => [
'exclude' => 0,
'label' => 'Anzahl Betten',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'int',
],
],
'parking_count' => [
'exclude' => 0,
'label' => 'Anzahl Parkplätze',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'int',
],
],
'altitude' => [
'exclude' => 0,
'label' => 'Höhe',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'trim',
],
],
'shower' => [
'exclude' => 0,
'label' => 'Dusche',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'trim',
],
],
'partyroom' => [
'exclude' => 0,
'label' => 'Partyraum',
'config' => [
'type' => 'check',
],
],
'gamesroom' => [
'exclude' => 0,
'label' => 'Spieleraum',
'config' => [
'type' => 'check',
],
],
'sauna' => [
'exclude' => 0,
'label' => 'Sauna',
'config' => [
'type' => 'check',
],
],
'wifi' => [
'exclude' => 0,
'label' => 'WiFi',
'config' => [
'type' => 'check',
],
],
'distance_bus' => [
'exclude' => 0,
'label' => 'Entfernung zum Bus',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'trim',
],
],
'distance_lift' => [
'exclude' => 0,
'label' => 'Entfernung zum Lift',
'config' => [
'type' => 'input',
'size' => 50,
'eval' => 'trim',
],
],
],
];
@@ -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',
@@ -260,6 +260,16 @@ CREATE TABLE tx_epproducts_domain_model_hotel
groups_price_tax_label varchar(255) DEFAULT '' NOT NULL,
main_season_from date DEFAULT NULL,
main_season_to date DEFAULT NULL,
beds_count int(11) DEFAULT '0' NOT NULL,
parking_count int(11) DEFAULT '0' NOT NULL,
altitude varchar(255) DEFAULT '' NOT NULL,
shower varchar(255) DEFAULT '' NOT NULL,
partyroom tinyint(4) unsigned DEFAULT '0' NOT NULL,
gamesroom tinyint(4) unsigned DEFAULT '0' NOT NULL,
sauna tinyint(4) unsigned DEFAULT '0' NOT NULL,
wifi tinyint(4) unsigned DEFAULT '0' NOT NULL,
distance_bus varchar(255) DEFAULT '' NOT NULL,
distance_lift varchar(255) DEFAULT '' NOT NULL,
tstamp int(11) unsigned DEFAULT '0' NOT NULL,
crdate int(11) unsigned DEFAULT '0' NOT NULL,
@@ -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";
@@ -93,17 +93,19 @@
</f:else>
</f:if>
</div>
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}"
additionalAttributes="{rel: 'nofollow'}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
<f:if condition="{teasers -> f:count()} > 3">
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}"
additionalAttributes="{rel: 'nofollow'}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
</f:if>
</f:if>
</div>
</f:if>
@@ -101,16 +101,18 @@
</div>
</f:else>
</f:if>
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
<f:if condition="{teasers -> f:count()} > 3">
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
</f:if>
</f:if>
</div>
</f:then>
@@ -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>
@@ -124,16 +124,18 @@
</div>
</f:else>
</f:if>
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
<f:if condition="{teasers -> f:count()} > 3">
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
</f:if>
</f:if>
</div>
</f:then>
@@ -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>
@@ -240,6 +240,7 @@
</f:if>
</ul>
<f:comment><!--
<f:if condition="{includedServicesAllDates}">
<div class="headline--3 headline--underlined">
Inklusivleistungen
@@ -259,6 +260,7 @@
</ul>
</div>
</f:if>
--></f:comment>
<f:comment><!--
<f:if condition="{includedServicesSomeDates}">
@@ -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>
@@ -89,16 +89,18 @@
</div>
</f:else>
</f:if>
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
<f:if condition="{teasers -> f:count()} > 3">
<f:if condition="{settings.hideMoreLink}">
<f:else>
<div class="text-right mt-4">
<f:link.typolink class="button bg-button"
parameter="{settings.defaultSearchPageUid}"
additionalParams="f={filterSettings -> ep:filterSettingsEncoder()}">
Alle anzeigen
</f:link.typolink>
</div>
</f:else>
</f:if>
</f:if>
</f:then>
<f:else>