feat: product search on TYPO3 13

Search page (searchresult plugin), AJAX results and filter panel, the
mobile filter overlay, search links ("Alle anzeigen", menu icon) and
the /reiseauswahl/{name} landing redirects are back. The search bar in
the page header stays out (SearchbarController not ported); on detail
pages the menu icon links to the search page instead of toggling it.

- Extbase requests are immutable: withArgument() instead of
  setArgument()
- StaticSearchParamsMiddleware runs after site resolution and before
  page resolution (the old position is a cycle in v13); FilterService
  copes with the missing TypoScript there, as TYPO3 10 did
- Search and filter forms are sent URL-encoded instead of multipart, so
  production can keep rejecting multipart requests
- ski-wochenenden: websiteTitle from the old sys_template sitetitle

Compared with TYPO3 10 on the same data (249 cases: search pages with
all 41 preset filters, landing redirects, AJAX results and filter
panel, GET and POST): 186 identical, 63 with the same results in a
different order (PHP 8 sorts stably, ties came out in arbitrary order
on PHP 7.4). Processed image names differ by their configuration hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-21 15:47:15 +02:00
1 parent 18757781bb
commit 72465053cd
36 files changed
+1643 -79

No files matched your search

+1
View File
@@ -57,3 +57,4 @@ routes:
route: robots.txt
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
websiteTitle: 'Ski-Wochenenden mit E&P Reisen und Events'
@@ -0,0 +1,101 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2021 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 Psr\Http\Message\ResponseInterface;
use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\SearchResultService;
use EP\EpProducts\Traits\RequestArgumentTypeConversionTrait;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class AjaxFilterPanelController extends ActionController
{
use RequestArgumentTypeConversionTrait;
/**
* @var FilterService
*/
protected $filterService;
/**
* @var SearchResultService
*/
private $searchResultService;
/**
* @param FilterService $filterService
*/
public function __construct(FilterService $filterService, SearchResultService $searchResultService)
{
$this->filterService = $filterService;
$this->searchResultService = $searchResultService;
}
public function initializeAction(): void
{
$forcedConceptUids = GeneralUtility::trimExplode(',', $this->settings['forcedConceptUids'] ?? '', true);
$this->processFilterSettingsArgument($forcedConceptUids);
}
/**
* @param array $filterSettings
*/
public function indexAction(array $filterSettings): ResponseInterface
{
$excludedConceptUids = GeneralUtility::trimExplode(',', $this->settings['excludedConceptUids'] ?? '');
$searchResult = $this
->searchResultService
->getResult($filterSettings, $excludedConceptUids);
$filterOptions = $this->filterService->collectFilterOptionsFromSearchResult($searchResult);
$resultsUri = $this->uriBuilder
->reset()
->setCreateAbsoluteUri(true)
->setTargetPageUid((int) ($this->settings['defaultSearchPageUid'] ?? null))
->uriFor(
'searchresult',
['filterSettings' => $filterSettings],
'Search',
'epproducts',
'searchresult'
);
$filterOptions->setResultsUri($resultsUri);
$this->view->assignMultiple([
'filterSettings' => $filterSettings,
'filterOptions' => $filterOptions,
'stimulusController' => 'mobile-search',
]);
return $this->htmlResponse();
}
}
@@ -0,0 +1,133 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use Psr\Http\Message\ResponseInterface;
use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\SearchResultService;
use EP\EpProducts\Traits\RequestArgumentTypeConversionTrait;
use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class AjaxSearchController extends ActionController
{
use RequestArgumentTypeConversionTrait;
/**
* @var FilterService
*/
protected $filterService;
/**
* @var SearchResultService
*/
protected $searchResultService;
/**
* @param FilterService $filterService
* @param SearchResultService $searchResultService
*/
public function __construct(FilterService $filterService, SearchResultService $searchResultService)
{
$this->filterService = $filterService;
$this->searchResultService = $searchResultService;
}
public function initializeSearchresultAction()
{
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'] ?? '', true);
$this->processFilterSettingsArgument($forcedConceptUids);
}
/**
* @param array $filterSettings
*/
public function searchresultAction(array $filterSettings): ResponseInterface
{
$dateSelected = false;
$organic = true;
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'] ?? '', true);
$excludedConceptUids = GeneralUtility::intExplode(',', $this->settings['excludedConceptUids'] ?? '', true);
if (isset($filterSettings['dateFrom']) || isset($filterSettings['dateTo'])) {
$dateSelected = true;
}
$searchResult = $this
->searchResultService
->getResult($filterSettings, $excludedConceptUids);
// No results? First retry without date constraint if any
if ((!empty($filterSettings['dateFrom']) || !empty($filterSettings['dateTo'])) && 0 === $searchResult->getTotal()) {
$filterSettings['dateFrom'] = null;
$filterSettings['dateTo'] = null;
$searchResult = $this->searchResultService->getResult($filterSettings, $excludedConceptUids);
$organic = false;
}
// Still no results? Remove all constraints and search again with defaults
if (0 === $searchResult->getTotal()) {
$filterSettings = $this->filterService->getNormalizedFilterSettings([], $forcedConceptUids);
$searchResult = $this->searchResultService->getResult($filterSettings, $excludedConceptUids);
$organic = false;
}
$filterOptions = $this->filterService->collectFilterOptionsFromSearchResult($searchResult);
$referringPageUri = $this
->uriBuilder
->reset()
->setTargetPageUid((int)($this->settings['defaultSearchPageUid'] ?? null))
->setCreateAbsoluteUri(true)
->uriFor(
'searchresult',
['filterSettings' => $filterSettings],
'Search',
'epproducts',
'searchresult'
)
;
$this->view->assignMultiple([
'filterSettings' => $filterSettings,
'filterOptions' => $filterOptions,
'searchResult' => $searchResult->getData(),
'referringPageUri' => $referringPageUri,
'stimulusController' => 'search',
'meta' => [
'organic' => $organic,
'total' => $searchResult->getTotal(),
'dateRange' => DateUtility::formatDateRange($filterSettings['dateFrom'], $filterSettings['dateTo']),
'dateSelected' => $dateSelected,
],
]);
return $this->htmlResponse();
}
}
@@ -106,11 +106,11 @@ class AjaxTableController extends ActionController
{
if ($this->request->hasArgument('dateFrom')) {
$dateFrom = $this->request->getArgument('dateFrom');
$this->request->setArgument('dateFrom', $dateFrom === '' ? null : $dateFrom);
$this->request = $this->request->withArgument('dateFrom', $dateFrom === '' ? null : $dateFrom);
}
if ($this->request->hasArgument('dateTo')) {
$dateTo = $this->request->getArgument('dateTo');
$this->request->setArgument('dateTo', $dateTo === '' ? null : $dateTo);
$this->request = $this->request->withArgument('dateTo', $dateTo === '' ? null : $dateTo);
}
}
@@ -0,0 +1,169 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use Psr\Http\Message\ResponseInterface;
use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\FilterSettingsEncoder;
use EP\EpProducts\Service\SearchResultService;
use EP\EpProducts\Traits\RequestArgumentTypeConversionTrait;
use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class SearchController extends ActionController
{
use RequestArgumentTypeConversionTrait;
/**
* @var FilterService
*/
protected $filterService;
/**
* @var SearchResultService
*/
private $searchResultService;
/**
* @param FilterService $filterService
*/
public function __construct(FilterService $filterService, SearchResultService $searchResultService)
{
$this->filterService = $filterService;
$this->searchResultService = $searchResultService;
}
public function initializeProcessSearchAction()
{
$this->processSearchParamsArgument();
}
/**
* @param array $searchParams
*/
public function processSearchAction(array $searchParams): ResponseInterface
{
$filterSettings = $this->filterService->getFilterSettingsFromSearchParams($searchParams);
$redirectUri = $this->uriBuilder
->reset()
->setCreateAbsoluteUri(true)
->uriFor(
'searchresult',
['filterSettings' => $filterSettings],
null,
null,
'searchresult'
);
return $this->redirectToUri($redirectUri);
}
public function initializeSearchresultAction()
{
// Process encoded filtersettings from static search result.
// Explicit filter settings win: they carry the visitor's current selection,
// whereas a stale 'f' riding along on a link would silently replace it.
$encodedFilterSettings = $this->request->getQueryParams()['f'] ?? null;
if ($encodedFilterSettings && !$this->request->hasArgument('filterSettings')) {
$filterSettingsEncoder = new FilterSettingsEncoder();
$filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings);
if (count($filterSettings) > 0) {
$this->request = $this->request->withArgument('filterSettings', $filterSettings);
}
}
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'] ?? '', true);
$this->processFilterSettingsArgument($forcedConceptUids);
}
/**
* @param array $filterSettings
*/
public function searchresultAction(array $filterSettings): ResponseInterface
{
$dateSelected = false;
$organic = true;
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'] ?? '', true);
$excludedConceptUids = GeneralUtility::intExplode(',', $this->settings['excludedConceptUids'] ?? '');
if (!empty($filterSettings['dateFrom']) || !empty($filterSettings['dateTo'])) {
$dateSelected = true;
}
$searchResult = $this
->searchResultService
->getResult($filterSettings, $excludedConceptUids);
// No results? First retry without date constraint if any
if ((!empty($filterSettings['dateFrom']) || !empty($filterSettings['dateTo'])) && 0 === $searchResult->getTotal()) {
$filterSettings['dateFrom'] = null;
$filterSettings['dateTo'] = null;
$searchResult = $this->searchResultService->getResult($filterSettings, $excludedConceptUids);
$organic = false;
}
// Still no results? Remove all constraints and search again with defaults
if (0 === $searchResult->getTotal()) {
$filterSettings = $this->filterService->getNormalizedFilterSettings([], $forcedConceptUids);
$searchResult = $this->searchResultService->getResult($filterSettings, $excludedConceptUids);
$organic = false;
}
$filterOptions = $this->filterService->collectFilterOptionsFromSearchResult($searchResult);
$referringPageUri = $this
->uriBuilder
->reset()
->setTargetPageUid($this->settings['defaultSearchPageUid'])
->setCreateAbsoluteUri(true)
->uriFor(
'searchresult',
['filterSettings' => $filterSettings],
'Search',
'epproducts',
'searchresult'
)
;
$this->view->assignMultiple([
'filterSettings' => $filterSettings,
'filterOptions' => $filterOptions,
'searchResult' => $searchResult->getData(),
'referringPageUri' => $referringPageUri,
'stimulusController' => 'search',
'meta' => [
'organic' => $organic,
'total' => $searchResult->getTotal(),
'dateRange' => DateUtility::formatDateRange($filterSettings['dateFrom'], $filterSettings['dateTo']),
'dateSelected' => $dateSelected,
],
]);
return $this->htmlResponse();
}
}
@@ -325,7 +325,7 @@ class FilterOptions implements \JsonSerializable
/**
* @return array
*/
public function jsonSerialize()
public function jsonSerialize(): mixed
{
return [
'priceRanges' => $this->getPriceRanges(),
@@ -153,7 +153,8 @@ abstract class AbstractRepository extends Repository
if (isset($filterSettings['priceRanges']) && is_array($filterSettings['priceRanges']) && count($filterSettings['priceRanges']) > 0) {
$orX = $qb->expr()->or();
foreach ($filterSettings['priceRanges'] as $priceRange) {
[ $priceMin, $priceMax ] = FilterOptions::$priceRangeOptions[$priceRange];
// An unknown range matches nothing, as on TYPO3 10
[ $priceMin, $priceMax ] = FilterOptions::$priceRangeOptions[$priceRange] ?? [ null, null ];
$orX = $orX->with($qb->expr()->and($qb->expr()->gte(
'date.min_price',
$qb->createNamedParameter($priceMin, Connection::PARAM_INT)
@@ -0,0 +1,71 @@
<?php
namespace EP\EpProducts\Middleware;
use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\FilterSettingsEncoder;
use EP\EpProducts\Traits\DbConnectionTrait;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class StaticSearchParamsMiddleware implements MiddlewareInterface
{
use DbConnectionTrait;
public const PREFIX_PATH_SEGMENT = 'reiseauswahl';
public const REDIRECT_RESPONSE_CODE = 302;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var NormalizedParams $params */
$params = $request->getAttribute('normalizedParams');
$uri = $params->getRequestUri();
if (strpos($uri, self::PREFIX_PATH_SEGMENT) === false) {
return $handler->handle($request);
}
// Ensure trailing slash for comparison
$uri = rtrim($uri, '/') . '/';
$qb = $this->getDbConnection()->createQueryBuilder();
$query = $qb
->select('*')
->from('tx_epproducts_domain_model_staticsearchparams')
->where('CONCAT("/", :prefix, "/", path_segment, "/")=LOWER(:uri)')
->andWhere('deleted = 0')
->andWhere('hidden = 0')
->setParameters([
'prefix' => self::PREFIX_PATH_SEGMENT,
'uri' => $uri,
])
;
$row = $query->executeQuery()->fetchAssociative();
if (!$row) {
return $handler->handle($request);
}
$filterService = GeneralUtility::makeInstance(FilterService::class);
$filterSettings = $filterService->getFilterSettingsFromStaticSearchParams($row);
$filterSettingsEncoder = new FilterSettingsEncoder();
$encodedFilterSettings = $filterSettingsEncoder->encode($filterSettings);
$searchPageUid = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products', 'searchPageUid');
$site = $request->getAttribute('site');
$redirectUri = $site->getRouter()->generateUri($searchPageUid, ['f' => $encodedFilterSettings]);
return new RedirectResponse($redirectUri, self::REDIRECT_RESPONSE_CODE);
}
}
@@ -333,8 +333,14 @@ class FilterService implements SingletonInterface
{
/** @var ConfigurationManager $configurationManager */
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
$settings = $configurationManager
->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT);
try {
$settings = $configurationManager
->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT);
} catch (\RuntimeException) {
// No TypoScript yet (StaticSearchParamsMiddleware runs before it): as on TYPO3 10, where
// the setup was simply empty at that point, the filter goes without productsPid
return null;
}
return $settings ? $settings['plugin.']['tx_epproducts.']['settings.'] : null;
}
@@ -103,29 +103,29 @@ class SearchResultService implements SingletonInterface
foreach ($searchResultData as $row) {
// Concepts
if (!array_key_exists($row['conceptUid'], $data)) {
$data[$row['conceptUid']] = [
$data[($row['conceptUid'] ?? null)] = [
'concept' => [
'uid' => $row['conceptUid'],
'name' => $row['conceptName'],
'code' => $row['conceptCode'],
'sorting' => $row['conceptSorting'],
'popupText' => $row['conceptPopupText'],
'uid' => ($row['conceptUid'] ?? null),
'name' => ($row['conceptName'] ?? null),
'code' => ($row['conceptCode'] ?? null),
'sorting' => ($row['conceptSorting'] ?? null),
'popupText' => ($row['conceptPopupText'] ?? null),
],
'items' => [],
];
}
$items =& $data[$row['conceptUid']]['items'];
$items =& $data[($row['conceptUid'] ?? null)]['items'];
$isAggregatedEvent = $aggregateEvents && 'evt' === $row['conceptCode'];
$isAggregatedEvent = $aggregateEvents && 'evt' === ($row['conceptCode'] ?? null);
// 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'];
? (string) ($row['productUid'] ?? null)
: ($row['hotelUid'] ?? null) . ':' . ($row['productUid'] ?? null);
$hotelCategory = Hotel::$categoryLabels[$row['hotelCategory']] ?? null;
$hotelCategory = Hotel::$categoryLabels[($row['hotelCategory'] ?? null)] ?? null;
if (!array_key_exists($key, $items)) {
$items[$key] = [
@@ -134,53 +134,53 @@ class SearchResultService implements SingletonInterface
'maxDateEnd' => null,
'minPrice' => null,
'detailPageUri' => null,
'earlybird' => $row['earlybird'],
'daytrip' => $row['daytrip'],
'season' => $row['season'],
'hideBookingButton' => $row['hideBookingButton'],
'busAvailable' => $row['busAvailable'],
'available' => $row['available'],
'lowContingent' => $row['lowContingent'],
'servicesIncluded' => unserialize($row['servicesIncluded'], ['allowed_classes' => false]),
'earlybird' => ($row['earlybird'] ?? null),
'daytrip' => ($row['daytrip'] ?? null),
'season' => ($row['season'] ?? null),
'hideBookingButton' => ($row['hideBookingButton'] ?? null),
'busAvailable' => ($row['busAvailable'] ?? null),
'available' => ($row['available'] ?? null),
'lowContingent' => ($row['lowContingent'] ?? null),
'servicesIncluded' => unserialize((string)($row['servicesIncluded'] ?? ''), ['allowed_classes' => false]),
'hotel' => [
'uid' => $row['hotelUid'],
'name' => $row['hotelName'],
'teaser' => $row['hotelTeaser'],
'uid' => ($row['hotelUid'] ?? null),
'name' => ($row['hotelName'] ?? null),
'teaser' => ($row['hotelTeaser'] ?? null),
'images' => $this->hotelImageService->getImages($row['hotelUid']),
'fact' => $row['hotelFact'],
'fact' => ($row['hotelFact'] ?? null),
'category' => $hotelCategory,
'type' => $row['hotelType'],
'type' => ($row['hotelType'] ?? null),
],
'country' => [
'uid' => $row['countryUid'],
'code' => $row['countryCode'],
'name' => $row['countryName'],
'uid' => ($row['countryUid'] ?? null),
'code' => ($row['countryCode'] ?? null),
'name' => ($row['countryName'] ?? null),
],
'region' => [
'uid' => $row['regionUid'],
'name' => $row['regionName'],
'fact' => $row['regionFact'],
'feature' => $row['regionFeature'],
'uid' => ($row['regionUid'] ?? null),
'name' => ($row['regionName'] ?? null),
'fact' => ($row['regionFact'] ?? null),
'feature' => ($row['regionFeature'] ?? null),
],
'product' => [
'uid' => $row['productUid'],
'detailPage' => $row['productDetailPage'],
'name' => $row['productName'],
'subline' => $row['productSubline'],
'feature' => $row['productFeature'],
'fact' => $row['productFact'],
'uid' => ($row['productUid'] ?? null),
'detailPage' => ($row['productDetailPage'] ?? null),
'name' => ($row['productName'] ?? null),
'subline' => ($row['productSubline'] ?? null),
'feature' => ($row['productFeature'] ?? null),
'fact' => ($row['productFact'] ?? null),
'images' => $isAggregatedEvent
? $this->productImageService->getImages($row['productUid'])
: [],
],
'board' => $row['board'],
'boardUid' => $row['boardUid'],
'board' => ($row['board'] ?? null),
'boardUid' => ($row['boardUid'] ?? null),
'dates' => [],
'nights' => [],
'teaserNights' => [],
'rooms' => [],
'new' => $row['new'],
'skiPassIncluded' => $row['skiPassIncluded'],
'new' => ($row['new'] ?? null),
'skiPassIncluded' => ($row['skiPassIncluded'] ?? null),
'filterMetadata' => [
'destinations' => [],
'hotelTypes' => [],
@@ -194,15 +194,15 @@ class SearchResultService implements SingletonInterface
$destination = [
'country' => [
'uid' => $row['countryUid'],
'code' => $row['countryCode'],
'name' => $row['countryName'],
'uid' => ($row['countryUid'] ?? null),
'code' => ($row['countryCode'] ?? null),
'name' => ($row['countryName'] ?? null),
],
'region' => [
'uid' => $row['regionUid'],
'name' => $row['regionName'],
'fact' => $row['regionFact'],
'feature' => $row['regionFeature'],
'uid' => ($row['regionUid'] ?? null),
'name' => ($row['regionName'] ?? null),
'fact' => ($row['regionFact'] ?? null),
'feature' => ($row['regionFeature'] ?? null),
],
];
if (!\in_array($destination, $item['filterMetadata']['destinations'], false)) {
@@ -212,7 +212,7 @@ class SearchResultService implements SingletonInterface
if (!\in_array($hotelType, $item['filterMetadata']['hotelTypes'], false)) {
$item['filterMetadata']['hotelTypes'][] = $hotelType;
}
$boardType = ['uid' => $row['boardUid'] ?? null, 'label' => $row['board']];
$boardType = ['uid' => $row['boardUid'] ?? null, 'label' => ($row['board'] ?? null)];
if (!\in_array($boardType, $item['filterMetadata']['boardTypes'], false)) {
$item['filterMetadata']['boardTypes'][] = $boardType;
}
@@ -224,15 +224,15 @@ class SearchResultService implements SingletonInterface
}
if (!\in_array($row['nights'], $item['nights'], false)) {
$item['nights'][] = $row['nights'];
$item['nights'][] = ($row['nights'] ?? null);
}
if (!\in_array($row['dateStart'], $item['dates'], false)) {
$item['dates'][] = $row['dateStart'];
$item['dates'][] = ($row['dateStart'] ?? null);
}
$dateStart = $row['dateStart'];
$dateEnd = $row['dateEnd'];
$dateStart = ($row['dateStart'] ?? null);
$dateEnd = ($row['dateEnd'] ?? null);
if ($item['minDateStart'] === null
|| $item['minDateStart'] > $dateStart) {
@@ -245,26 +245,26 @@ class SearchResultService implements SingletonInterface
}
if ($item['minPrice'] === null
|| $item['minPrice'] > $row['minPrice']) {
$item['minPrice'] = $row['minPrice'];
|| $item['minPrice'] > ($row['minPrice'] ?? null)) {
$item['minPrice'] = ($row['minPrice'] ?? null);
// 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'];
$item['teaserNights'] = [($row['nights'] ?? null)];
$item['board'] = ($row['board'] ?? null);
$item['boardUid'] = ($row['boardUid'] ?? null);
$item['daytrip'] = ($row['daytrip'] ?? null);
$item['season'] = ($row['season'] ?? null);
$item['hideBookingButton'] = ($row['hideBookingButton'] ?? null);
$item['servicesIncluded'] = unserialize((string)($row['servicesIncluded'] ?? ''), ['allowed_classes' => false]);
$item['new'] = ($row['new'] ?? null);
$item['skiPassIncluded'] = ($row['skiPassIncluded'] ?? null);
}
// 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'];
$item['earlybird'] = (bool) $item['earlybird'] || (bool) ($row['earlybird'] ?? null);
$item['busAvailable'] = (bool) $item['busAvailable'] || (bool) ($row['busAvailable'] ?? null);
$item['lowContingent'] = (bool) $item['lowContingent'] || (bool) ($row['lowContingent'] ?? null);
$item['available'] = (bool) $item['available'] || (bool) ($row['available'] ?? null);
}
@@ -275,7 +275,7 @@ class SearchResultService implements SingletonInterface
}
uasort($section['items'], function ($a, $b) {
return strcmp($a['minPrice'], $b['minPrice']);
return strcmp((string)$a['minPrice'], (string)$b['minPrice']);
});
}
@@ -24,7 +24,7 @@ trait RequestArgumentTypeConversionTrait
$searchParams = $searchResultService->getNormalizedSearchParams($searchParamsFromRequest);
}
$this->request->setArgument('searchParams', $searchParams);
$this->request = $this->request->withArgument('searchParams', $searchParams);
}
public function processFilterSettingsArgument(array $forcedConceptUids = []): void
@@ -42,7 +42,7 @@ trait RequestArgumentTypeConversionTrait
$filterSettings = $this->filterService->getNormalizedFilterSettings($filterSettingsFromRequest, $forcedConceptUids);
}
$this->request->setArgument('filterSettings', $filterSettings);
$this->request = $this->request->withArgument('filterSettings', $filterSettings);
}
}
@@ -5,6 +5,15 @@ use EP\EpProducts\Middleware\ContentPageTypeGuardMiddleware;
return [
'frontend' => [
'ep/staticsearch' => [
'target' => \EP\EpProducts\Middleware\StaticSearchParamsMiddleware::class,
'before' => [
'typo3/cms-frontend/page-resolver',
],
'after' => [
'typo3/cms-frontend/site',
],
],
'ep/productapi' => [
'target' => ApiMiddleware::class,
'before' => [
@@ -7,6 +7,12 @@ defined('TYPO3') or die();
call_user_func(function () {
ExtensionUtility::registerPlugin(
'EpProducts',
'searchresult',
'Suchergebnis'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'product_detail',
@@ -0,0 +1,243 @@
<?php
use EP\EpProducts\Domain\Model\Hotel;
return [
'ctrl' => [
'title' => 'LLL:EXT:ep_products/Resources/Private/Language/locallang.xlf:tx_epproducts_domain_model_staticsearchparams',
'label' => 'name',
'prependAtCopy' => '[COPY %s]',
'default_sortby' => 'ORDER BY name',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'dividers2tabs' => true,
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'delete' => 'deleted',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'name,path_segment',
'iconfile' => 'EXT:ep_theme/Resources/Public/images/icon_ce.svg',
],
'types' => [
'1' => [
'showitem' => 'name, path_segment, country, region, concept, hotel, product, date_from,
date_to, hotel_types, bus, pax',
],
],
'palettes' => [],
'columns' => [
'sys_language_uid' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'items' => [
['label' => '', 'value' => 0],
],
'foreign_table' => 'tx_epproducts_domain_model_staticsearchparams',
'foreign_table_where' => 'AND tx_epproducts_domain_model_staticsearchparams.pid=###CURRENT_PID### AND tx_epproducts_domain_model_staticsearchparams.sys_language_uid IN (-1,0)',
],
],
'l10n_diffsource' => [
'config' => [
'type' => 'passthrough',
],
],
't3ver_label' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.versionLabel',
'config' => [
'type' => 'input',
'size' => 30,
'max' => 255,
]
],
'hidden' => [
'exclude' => 0,
'label' => 'LLL:EXT:ep_products/Resources/Private/Language/locallang.xlf:tx_epproducts_domain_model_product.hidden',
'config' => [
'type' => 'check',
],
],
'starttime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
'config' => [
'type' => 'datetime',
'size' => 13,
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
],
],
'endtime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
'config' => [
'type' => 'datetime',
'size' => 13,
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
],
],
'bus' => [
'exclude' => 0,
'label' => 'Bus',
'config' => [
'type' => 'check',
],
],
'name' => [
'exclude' => 0,
'label' => 'Name',
'config' => [
'type' => 'input',
'cols' => 30,
'eval' => 'trim',
'required' => true
],
],
'path_segment' => [
'exclude' => 0,
'label' => 'URL Segment',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim,unique,alphanum_x,lower',
'required' => true
],
],
'concept' => [
'exclude' => 0,
'label' => 'Konzept',
'config' => [
'type' => 'select',
'renderType' => 'selectSingleBox',
'foreign_table' => 'tx_epproducts_domain_model_concept',
'foreign_table_where' => 'AND 1=1 ORDER BY name',
'minItems' => 0,
'maxitems' => 10,
'size' => 5,
'items' => [
[ 'label' => 'Keine Auswahl', 'value' => 0 ],
]
],
],
'country' => [
'exclude' => 0,
'label' => 'Land',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_country',
'foreign_table_where' => 'AND 1=1 ORDER BY name',
'minitems' => 0,
'maxitems' => 1,
'items' => [
[ 'label' => 'Keine Auswahl', 'value' => 0 ],
]
],
],
'region' => [
'exclude' => 0,
'label' => 'Gebiet',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_region',
'foreign_table_where' => 'AND 1=1 ORDER BY name',
'minitems' => 0,
'maxitems' => 1,
'items' => [
[ 'label' => 'Keine Auswahl', 'value' => 0 ],
]
],
],
'hotel' => [
'exclude' => 0,
'label' => 'Hotel',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_hotel',
'foreign_table_where' => 'AND 1=1 ORDER BY tx_epproducts_domain_model_hotel.name',
'minitems' => 0,
'maxitems' => 1,
'items' => [
[ 'label' => 'Keine Auswahl', 'value' => 0 ],
]
],
],
'hotel_types' => [
'exclude' => 0,
'label' => 'Unterkunftsarten',
'config' => [
'type' => 'select',
'renderType' => 'selectCheckBox',
'items' => [
[ 'label' => 'Keine Auswahl', 'value' => 0 ],
[ 'label' => 'Standard', 'value' => Hotel::CATEGORY_STANDARD ],
[ 'label' => 'Komfort', 'value' => Hotel::CATEGORY_COMFORT ],
[ 'label' => 'Deluxe', 'value' => Hotel::CATEGORY_DELUXE ],
[ 'label' => 'Superdeluxe', 'value' => Hotel::CATEGORY_SUPER_DELUXE ],
],
'minitems' => 0,
'maxitems' => 99,
]
],
'date_from' => [
'exclude' => 0,
'label' => 'Datum von',
'config' => [
'dbType' => 'date',
'type' => 'datetime',
'size' => 7,
'checkbox' => 0,
'format' => 'date',
]
],
'date_to' => [
'exclude' => 0,
'label' => 'Datum bis',
'config' => [
'dbType' => 'date',
'type' => 'datetime',
'size' => 7,
'checkbox' => 0,
'format' => 'date',
]
],
'pax' => [
'exclude' => 0,
'label' => 'Personen',
'config' => [
'type' => 'number',
'size' => 2,
]
]
],
];
+18
View File
@@ -1,6 +1,9 @@
<?php
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
use EP\EpProducts\Controller\ProductController;
use EP\EpProducts\Controller\SearchController;
use EP\EpProducts\Controller\AjaxFilterPanelController;
use EP\EpProducts\Controller\AjaxSearchController;
use EP\EpProducts\Controller\AjaxTableController;
use EP\EpProducts\Controller\AjaxCalendarController;
use EP\EpProducts\Controller\AjaxTeaserPopupController;
@@ -48,15 +51,30 @@ $boot = function () {
]
);
ExtensionUtility::configurePlugin(
'EpProducts',
'searchresult',
[
SearchController::class => 'searchresult,processSearch',
],
[
SearchController::class => 'searchresult,processSearch',
]
);
ExtensionUtility::configurePlugin(
'EpProducts',
'ajax',
[
AjaxSearchController::class => 'searchresult',
AjaxFilterPanelController::class => 'index',
AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable',
AjaxCalendarController::class => 'priceCalendar,priceTable',
AjaxTeaserPopupController::class => 'concept',
],
[
AjaxSearchController::class => 'searchresult',
AjaxFilterPanelController::class => 'index',
AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable',
AjaxTeaserPopupController::class => 'concept',
]
@@ -0,0 +1,59 @@
<?php
namespace EP\EpTheme\ViewHelpers;
/***************************************************************
*
* Copyright notice
*
* (c) 2021 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 TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
class PriceRangeViewHelper extends AbstractViewHelper
{
public function initializeArguments()
{
parent::initializeArguments();
$this->registerArgument('min', 'mixed', 'Price range start');
$this->registerArgument('max', 'mixed', 'Price range end');
}
/**
* @param array $arguments
* @param \Closure $renderChildrenClosure
* @param RenderingContextInterface $renderingContext
* @return mixed
*/
public function render()
{
if (1 === (int) $this->arguments['min']) {
return sprintf('bis %d €', $this->arguments['max']);
}
if (999 === (int) $this->arguments['max']) {
return sprintf('ab %d €', $this->arguments['min']);
}
return sprintf('%d bis %d €', $this->arguments['min'], $this->arguments['max']);
}
}
@@ -39,7 +39,8 @@ export default class extends Controller {
filter() {
this.load({
method: 'POST',
body: new FormData(this.formTarget)
// URL-encoded, not multipart: production rejects multipart requests (CVE-2026-109618)
body: new URLSearchParams(new FormData(this.formTarget))
})
}
@@ -23,7 +23,8 @@ export default class extends Controller {
filter() {
this.load({
method: 'POST',
body: new FormData(this.formTarget)
// URL-encoded, not multipart: production rejects multipart requests (CVE-2026-109618)
body: new URLSearchParams(new FormData(this.formTarget))
})
}
@@ -14,6 +14,7 @@
<aside>
<f:render partial="ContactBar" arguments="{_all}"/>
<f:render partial="Mobilenav" section="Main" arguments="{_all}"/>
<f:render partial="Search/MobileSearch" arguments="{_all}"/>
</aside>
<f:render partial="Config" arguments="{_all}" />
</html>
@@ -0,0 +1,18 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:render partial="Topnav" arguments="{_all}"/>
<f:render partial="Mainnav" section="Main" arguments="{_all}"/>
<main id="main-content">
<f:render section="Content"/>
</main>
<footer>
<f:render partial="Footer" arguments="{_all}"/>
</footer>
<aside>
<f:render partial="ContactBar" arguments="{_all}"/>
<f:render partial="Mobilenav" section="Main" arguments="{_all}"/>
</aside>
<f:render partial="Config" arguments="{_all}" />
</html>
@@ -17,5 +17,10 @@
<f:render partial="ContactBar" arguments="{_all}"/>
<f:render partial="Topnav" arguments="{_all}"/>
<f:render partial="Mobilenav" section="Main" arguments="{_all}"/>
<f:if condition="{data.tx_eptheme_hide_searchbar} || {settings.hideSearchbar}">
<f:else>
<f:render partial="Search/MobileSearch" arguments="{_all}"/>
</f:else>
</f:if>
<f:render partial="Config" arguments="{_all}" />
</html>
@@ -25,5 +25,6 @@
<f:render partial="ContactBar" arguments="{_all}"/>
<f:render partial="Topnav" arguments="{_all}"/>
<f:render partial="Mobilenav" section="Main" arguments="{_all}"/>
<f:render partial="Search/MobileSearch" arguments="{_all}"/>
<f:render partial="Config" arguments="{_all}" />
</html>
@@ -93,6 +93,20 @@
</f:else>
</f:if>
</div>
<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>
<f:if condition="{city.descriptionExtended}">
@@ -98,6 +98,19 @@
</div>
</f:else>
</f:if>
<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>
<f:else>
@@ -17,6 +17,18 @@
<f:render section="FirstLevel" arguments="{_all}"/>
</div>
<div class="flex hover-hover:lg:hidden items-center space-x-4">
<f:if condition="{settings.hideSearchbar}">
<f:else>
<button type="button"
class="focus:outline-none text-zinc-800"
aria-label="Reisen suchen"
data-controller="overlay-toggle"
data-overlay-toggle-id-value="mobile-search"
data-action="overlay-toggle#invokeToggle">
{ep:icon(icon: 'search', class: 'w-8 h-8 text-zinc-800')}
</button>
</f:else>
</f:if>
<button type="button"
class="focus:outline-none text-zinc-800"
aria-label="Navigation öffnen"
@@ -61,6 +73,27 @@
</f:if>
</li>
</f:for>
<f:if condition="{data.tx_eptheme_hide_searchbar} || {settings.hideSearchbar}">
<f:else>
<li class="pb-4" role="none">
<f:if condition="{searchbarCollapsed}">
<f:then>
<button type="button"
aria-label="Suche anzeigen/ausblenden"
data-controller="searchbar-toggle"
data-action="searchbar-toggle#toggle">
{ep:icon(icon: 'search', class: 'w-6 h-6')}
</button>
</f:then>
<f:else>
<f:link.typolink parameter="{settings.defaultSearchPageUid}" title="zur Suche" additionalAttributes="{'role': 'menuitem'}">
{ep:icon(icon: 'search', class: 'w-6 h-6')}
</f:link.typolink>
</f:else>
</f:if>
</li>
</f:else>
</f:if>
</ul>
</nav>
</f:section>
@@ -17,6 +17,18 @@
<f:image class="block h-10 lg:h-12 w-auto" src="{settings.logoImage}" alt="Bildmarke"/>
</f:link.typolink>
<div class="flex hover-hover:lg:hidden items-center space-x-4">
<f:if condition="{data.tx_eptheme_hide_searchbar}">
<f:else>
<button type="button"
class="focus:outline-none text-zinc-800"
aria-label="Reisen suchen"
data-controller="overlay-toggle"
data-overlay-toggle-id-value="search"
data-action="overlay-toggle#invokeToggle">
{ep:icon(icon: 'search', class: 'w-8 h-8 text-zinc-800')}
</button>
</f:else>
</f:if>
<button type="button"
class="focus:outline-none"
aria-label="Navigation schließen"
@@ -124,6 +124,19 @@
</div>
</f:else>
</f:if>
<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>
<f:else>
@@ -0,0 +1,348 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="mb-4"
data-controller="collapse"
data-collapse-until-value="960">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full bg-brand-gradient p-4">
{ep:icon(icon: 'calendar', class: 'w-6 h-6 text-white mr-2')}
<span class="flex-1 uppercase font-bold text-white text-left">
Reisezeitraum
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<f:form.hidden property="dateFrom"
data="{'search-target': 'dateFrom', 'mobile-search-target': 'dateFrom'}"/>
<f:form.hidden property="dateTo"
data="{'search-target': 'dateTo', 'mobile-search-target': 'dateTo'}"/>
<div class="bg-brand-gradient p-4"
data-controller="datepicker"
data-datepicker-inline-value="false"
data-datepicker-date-from-value="{filterSettings.dateFrom}"
data-datepicker-date-to-value="{filterSettings.dateTo}"
data-datepicker-min-date-value="{f:format.date(date: 'now', format: 'Y-m-d')}">
<div class="mt-2">
<div class="flex flex-col">
<div class="relative pb-2 md:pb-0">
<input type="text"
aria-label="Reisezeitraum"
class="border-px border-white text-white text-xl p-2 w-full bg-transparent focus:border-white focus:ring-0"
data-datepicker-target="field">
{ep:icon(icon: 'calendar', class: 'w-6 h-6 text-white absolute top-0 right-0 translate-y-1/2 mr-2')}
</div>
<f:if condition="{settings.datePickerPresets}">
<div class="flex flex-col divide-y divide-white/85 mt-2">
<f:for each="{settings.datePickerPresets}" as="preset">
<button type="button"
class="flex items-center justify-between w-full text-white py-2"
data-preset='{preset -> f:format.json() -> f:format.raw()}'
data-action="datepicker#selectPreset">
<span class="uppercase font-bold">{preset.label}</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6')}
</button>
</f:for>
</div>
</f:if>
<button type="button"
class="text-white text-left text-sm mt-2"
data-action="datepicker#reset">
zurücksetzen
</button>
</div>
</div>
</div>
</div>
</div>
<div class="mb-4"
data-controller="collapse"
data-collapse-until-value="960">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full bg-brand-gradient p-4">
{ep:icon(icon: 'concept', class: 'w-6 h-6 text-white mr-2')}
<span class="flex-1 uppercase font-bold text-white text-left">
Reisekonzepte
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container"
class="p-4 border-t border-white/85 bg-brand-gradient">
<ul class="mb-0 p-0 space-y-2">
<f:for each="{filterOptions.concepts}" as="concept">
<li>
<label class="flex items-center" for="form-concept-{concept.uid}">
<f:form.checkbox class="mr-2 rounded-full w-5 h-5 border-white ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
id="form-concept-{concept.uid}"
property="conceptUids"
data="{action: '{stimulusController}#filter'}"
value="{concept.uid}"/>
<span class="block w-full py-1 px-2 bg-concept-{concept.code} text-white text-lg rounded">{concept.name}</span>
</label>
</li>
</f:for>
</ul>
</div>
</div>
<f:if condition="{filterOptions.resultsUri}">
<div class="my-4">
<a href="{filterOptions.resultsUri}"
class="button text-white bg-badge-gradient w-full">
{filterOptions.resultCount} Treffer anzeigen
</a>
</div>
</f:if>
<div class="divide-y py-2 px-4 hover-hover:lg:bg-white">
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.countryUids} || {filterSettings.regionUids}', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Reiseziel
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4">
<f:for each="{filterOptions.destinations}" as="destination">
<li class="relative border-b last:border-0 mb-2">
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="countryUids"
data="{action: '{stimulusController}#filter'}"
value="{destination.country.uid}"/>
<span class="pl-2 leading-none">
<strong>{destination.country.label}</strong>
</span>
{ep:icon(icon: 'flag-{destination.country.code -> f:format.case(mode: \'lower\')}', class: 'w-8 h-6 absolute top-0 right-0')}
</label>
<f:if condition="{destination.regions}">
<ul class="py-2 space-y-2">
<f:for each="{destination.regions}" as="region">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="regionUids"
data="{action: '{stimulusController}#filter'}"
value="{region.uid}"/>
<span class="leading-none pl-2">
{region.label}
</span>
</label>
</li>
</f:for>
</ul>
</f:if>
</li>
</f:for>
</ul>
</div>
</div>
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.priceRanges}', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Preis
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.priceRanges}" as="range">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="priceRanges"
data="{action: '{stimulusController}#filter'}"
value="{range.uid}"/>
<span class="leading-none pl-2">
{ep:priceRange(min: range.min, max: range.max)}
</span>
</label>
</li>
</f:for>
</ul>
</div>
</div>
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.pax} > 1', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Personen
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<f:form.select class="w-full border-0 focus:ring-0 bg-right bg-transparent text-white lg:bg-white lg:text-zinc-800 mt-2 p-0"
property="pax"
options="{filterOptions.pax}"
data="{action: '{stimulusController}#filter'}"/>
</div>
</div>
<f:variable name="hotelTypes" value="{
1: 'Ferienwohnung',
2: 'Sportclub',
3: 'Appartment',
4: 'Gästehaus',
5: 'Pension'
}"/>
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.hotelTypes}', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Unterkunft
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.hotelTypes}" as="type">
<f:if condition="{hotelTypes.{type}}">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="hotelTypes"
data="{action: '{stimulusController}#filter'}"
value="{type}"/>
<span class="leading-none pl-2">
{hotelTypes.{type}}
</span>
</label>
</li>
</f:if>
</f:for>
</ul>
</div>
</div>
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.boardTypes}', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Verpflegung
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.boardTypes}" as="type">
<f:if condition="{type.label}">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="boardTypes"
data="{action: '{stimulusController}#filter'}"
value="{type.uid}"/>
<span class="pl-2 leading-none">
{type.label}
</span>
</label>
</li>
</f:if>
</f:for>
</ul>
</div>
</div>
<f:variable name="roomTypes" value="{
1: 'EZ',
2: 'DZ / 2er',
3: '3er',
4: '4er',
5: '5er',
6: '6er',
7: '7er',
8: '8er',
9: '9er und mehr',
10: 'mit anderen'
}"/>
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: '{filterSettings.roomTypes}', then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Zimmerarten/Apart.
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6 hover-none:text-white', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.roomTypes}" as="type">
<f:if condition="{roomTypes.{type}}">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="roomTypes"
data="{action: '{stimulusController}#filter'}"
value="{type}"/>
<span class="leading-none pl-2">
{roomTypes.{type}}
</span>
</label>
</li>
</f:if>
</f:for>
</ul>
</div>
</div>
<f:if condition="{filterOptions.busAvailable}">
<div class="py-2"
data-controller="collapse"
data-collapse-collapsed-value="{f:if(condition: filterSettings.bus, then: 'false', else: 'true')}">
<button type="button"
data-action="click->collapse#toggle"
class="flex items-center justify-between w-full">
<span class="uppercase font-bold">
Anreise
</span>
{ep:icon(icon: 'arrow-right', class: 'w-6 h-6', data: '{collapse-target: \'icon\'}')}
</button>
<div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2">
<li>
<label class="flex items-start">
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
property="bus"
data="{action: '{stimulusController}#filter'}"
value="1"/>
<span class="leading-none pl-2">
Busanreise
</span>
</label>
</li>
</ul>
</div>
</div>
</f:if>
</div>
</html>
@@ -0,0 +1,35 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="grid grid-cols-3 gap-8">
<div class="col-span-3 lg:col-span-1 relative">
<div class="hidden lg:block">
<h3 class="headline headline--underlined mb-4">
Filter
</h3>
<f:form object="{filterSettings}"
name="filterSettings"
pluginName="Ajax"
data="{'search-target': 'form'}">
<f:render partial="Search/FilterPanel" arguments="{_all}"/>
</f:form>
<div class="mt-4">
<button type="button"
class="button bg-button bg-button--secondary w-full"
data-action="search#reset">
Filter zurücksetzen
</button>
</div>
</div>
</div>
<div class="col-span-3 lg:col-span-2 relative">
<h3 class="headline headline--underlined mb-4">
Suchergebnis
</h3>
<f:render partial="Search/Result" arguments="{_all}"/>
</div>
<f:render partial="Search/MobileSearch" arguments="{_all}"/>
</div>
</html>
@@ -0,0 +1,34 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
data-namespace-typo3-fluid="true">
<div class="fixed hover-hover:lg:hidden top-0 left-0 inset-0 w-screen h-screen pt-14 hover-none:lg:pt-20 pb-10 z-50 bg-zinc-700 hidden"
data-controller="mobile-search"
data-mobile-search-uri-value="{ep:uri.ajax(controller: 'AjaxFilterPanel', action: 'index', pageUid: settings.defaultAjaxUid, format: 'html')}"
data-mobile-search-init-uri-value="{ep:uri.ajax(controller: 'AjaxFilterPanel', action: 'index', pageUid: settings.defaultAjaxUid, arguments: '{filterSettings: filterSettings}', format: 'html')}"
data-action="overlay-toggle:toggle@window->mobile-search#open datepicker:range-select->mobile-search#selectDateRange">
<div class="fixed top-0 left-0 z-100 inset-x-0 px-4 py-2 hover-none:lg:py-4 flex justify-between bg-white shadow-md">
<a href="{f:uri.typolink(parameter: settings.defaultHomeUid)}"
class="block hover-hover:lg:pb-4"
title="zur Startseite">
<f:image class="block h-10 lg:h-12 w-auto" src="{settings.logoImage}" alt="Bildmarke"/>
</a>
<button type="button"
aria-label="Suche schließen"
class="focus:outline-none" data-action="mobile-search#close">
{ep:icon(icon: 'close', class: 'w-8 h-8')}
</button>
</div>
<div data-mobile-search-target="container"
class="h-full w-full overflow-y-scroll px-4 pt-4 pb-24 bg-ep-primary-dark text-white">
</div>
<div class="fixed top-0 left-0 z-20 w-full h-full hidden"
data-mobile-search-target="loadingIndicator">
<div class="absolute top-1/2 left-1/2 -translate-xy-1/2 flex items-center space-x-4 bg-ep-secondary text-white text-lg p-4 rounded">
{ep:icon(icon: 'spinner', class: 'w-6 h-6')}
<span>Suche wird geladen...</span>
</div>
</div>
</div>
</html>
@@ -0,0 +1,114 @@
<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 class="flex mb-4 border-2 rounded-sm {f:if(condition: '{meta.organic} && {meta.total} > 0', then: 'border-emerald-600', else: 'border-red-600')}">
<div class="flex flex-col items-center justify-center px-2 {f:if(condition: '{meta.organic} && {meta.total} > 0', then: 'bg-emerald-600', else: 'bg-red-600')}">
<f:if condition="{meta.organic} && {meta.total} > 0">
<f:then>
{ep:icon(icon: 'emoji-happy', class: 'w-6 h-6 text-white')}
</f:then>
<f:else>
{ep:icon(icon: 'emoji-sad', class: 'w-6 h-6 text-white')}
</f:else>
</f:if>
</div>
<div class="flex-1 px-4 py-2 bg-white text-lg">
Deine Suche
<f:if condition="{meta.organic}">
<f:then>
<f:if condition="{meta.destinationName}">
nach <strong>{meta.destinationName}</strong>
</f:if>
<f:if condition="{meta.dateRange}">
<strong>{meta.dateRange}</strong>
</f:if>
ergab {meta.total} Treffer
</f:then>
<f:else>
ergab leider keine genauen Treffer. Bitte benutze die Filter.
</f:else>
</f:if>
</div>
</div>
<button type="button"
class="lg:hidden mb-4 text-white bg-badge-gradient uppercase py-2 w-full"
data-controller="overlay-toggle"
data-overlay-toggle-id-value="mobile-search"
data-action="overlay-toggle#invokeToggle">
Filter
</button>
<f:for each="{searchResult}" as="section">
<div id="concept-{section.concept.uid}">
<h4 class="headline headline--tertiary headline--underlined">
{section.concept.name}
</h4>
<div class="pb-4 md:pb-8"
data-controller="teasergrid"
data-teasergrid-grid-class="grid-cols-2"
data-teasergrid-disabled-value="false">
<div class="flex items-center space-x-1 mb-2 sm:hidden">
<button type="button"
class="opacity-50"
aria-label="kompakte Ansicht"
data-action="teasergrid#grid"
data-teasergrid-target="button">
{ep:icon(icon: 'teaser-grid', class: 'w-5 h-5 text-ep-primary')}
</button>
<button type="button"
aria-label="vergrößerte Ansicht"
data-action="teasergrid#block"
data-teasergrid-target="button">
{ep:icon(icon: 'teaser-single', class: 'w-5 h-5 text-ep-primary')}
</button>
</div>
<div class="grid grid-cols-2 xl:grid-cols-3 gap-4 md:gap-8"
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: searchProductImage,
nights: item.teaserNights,
board: item.board,
minPrice: item.minPrice,
daytrip: item.daytrip,
season: item.season,
dateFrom: searchDateFrom,
dateTo: searchDateTo,
forceHotelUid: searchForceHotelUid,
showHotelDetails: searchShowHotelDetails,
hideWatchlist: searchHideWatchlist,
new: item.new,
skiPassIncluded: item.skiPassIncluded,
referringPageUri: referringPageUri
}"/>
</f:for>
</div>
</div>
</div>
</f:for>
</html>
@@ -0,0 +1,32 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<f:form object="{filterSettings}"
name="filterSettings"
pluginName="Ajax"
action=""
data="{'mobile-search-target': 'form'}">
<f:render partial="Search/FilterPanel" arguments="{_all}"/>
</f:form>
<f:if condition="{filterOptions.resultsUri}">
<div class="mt-4">
<a href="{filterOptions.resultsUri}"
class="button text-white bg-badge-gradient w-full">
{filterOptions.resultCount} Treffer anzeigen
</a>
</div>
</f:if>
<div class="mt-4">
<button type="button"
class="button bg-button bg-button--secondary w-full"
data-action="mobile-search#reset">
Filter zurücksetzen
</button>
</div>
</f:section>
</html>
@@ -0,0 +1,19 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<f:render partial="Search/Main" arguments="{_all}"/>
</f:section>
<f:section name="SearchResult">
<f:render partial="Search/Result" arguments="{_all}"/>
</f:section>
<f:section name="FilterPanel">
<f:render partial="Search/FilterPanel" arguments="{_all}"/>
</f:section>
</html>
@@ -0,0 +1,11 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:layout name="Page/Search"/>
<f:section name="Content">
<f:cObject typoscriptObjectPath="lib.dynamicContent" data="{pageUid: '{data.uid}', colPos: '0'}" />
</f:section>
</html>
@@ -89,6 +89,19 @@
</div>
</f:else>
</f:if>
<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>
<f:if condition="{nonBookableInfo}">
@@ -0,0 +1,29 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Default"/>
<f:section name="Main">
<div class="pb-8 md:py-8 bg-ep-primary-bg">
<div class="container"
data-controller="search"
data-search-uri-value="{ep:uri.ajax(controller: 'AjaxSearch', action: 'searchresult', pageUid: settings.defaultAjaxUid, format: 'html')}"
data-action="datepicker:range-select->search#selectDateRange">
<div data-search-target="resultsContainer">
<f:render partial="Search/Main" arguments="{_all}"/>
</div>
<div class="fixed top-0 left-0 z-20 w-screen h-screen hidden"
data-search-target="loadingIndicator">
<div class="absolute top-1/2 left-1/2 transform -translate-xy-1/2 flex items-center space-x-4 bg-badge-gradient text-white text-lg p-4 rounded">
<svg class="w-6 h-6">
<use href="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/icons_sprite.svg')}#icon-spinner"></use>
</svg>
<span>Suchergebnis wird geladen...</span>
</div>
</div>
</div>
</div>
</f:section>
</html>