diff --git a/config/sites/ski-wochenenden/config.yaml b/config/sites/ski-wochenenden/config.yaml
index ab2a219..6a7479e 100644
--- a/config/sites/ski-wochenenden/config.yaml
+++ b/config/sites/ski-wochenenden/config.yaml
@@ -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'
diff --git a/packages/ep_products/Classes/Controller/AjaxFilterPanelController.php b/packages/ep_products/Classes/Controller/AjaxFilterPanelController.php
new file mode 100644
index 0000000..1ec7488
--- /dev/null
+++ b/packages/ep_products/Classes/Controller/AjaxFilterPanelController.php
@@ -0,0 +1,101 @@
+, 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();
+ }
+
+}
diff --git a/packages/ep_products/Classes/Controller/AjaxSearchController.php b/packages/ep_products/Classes/Controller/AjaxSearchController.php
new file mode 100644
index 0000000..e8dc32b
--- /dev/null
+++ b/packages/ep_products/Classes/Controller/AjaxSearchController.php
@@ -0,0 +1,133 @@
+, 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();
+ }
+
+}
diff --git a/packages/ep_products/Classes/Controller/AjaxTableController.php b/packages/ep_products/Classes/Controller/AjaxTableController.php
index afd71e4..9e23dcd 100644
--- a/packages/ep_products/Classes/Controller/AjaxTableController.php
+++ b/packages/ep_products/Classes/Controller/AjaxTableController.php
@@ -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);
}
}
diff --git a/packages/ep_products/Classes/Controller/SearchController.php b/packages/ep_products/Classes/Controller/SearchController.php
new file mode 100644
index 0000000..0c89099
--- /dev/null
+++ b/packages/ep_products/Classes/Controller/SearchController.php
@@ -0,0 +1,169 @@
+, 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();
+ }
+}
diff --git a/packages/ep_products/Classes/Domain/Model/Dto/FilterOptions.php b/packages/ep_products/Classes/Domain/Model/Dto/FilterOptions.php
index 1b95aba..ffcb801 100644
--- a/packages/ep_products/Classes/Domain/Model/Dto/FilterOptions.php
+++ b/packages/ep_products/Classes/Domain/Model/Dto/FilterOptions.php
@@ -325,7 +325,7 @@ class FilterOptions implements \JsonSerializable
/**
* @return array
*/
- public function jsonSerialize()
+ public function jsonSerialize(): mixed
{
return [
'priceRanges' => $this->getPriceRanges(),
diff --git a/packages/ep_products/Classes/Domain/Repository/AbstractRepository.php b/packages/ep_products/Classes/Domain/Repository/AbstractRepository.php
index d3ed3a0..20470f3 100644
--- a/packages/ep_products/Classes/Domain/Repository/AbstractRepository.php
+++ b/packages/ep_products/Classes/Domain/Repository/AbstractRepository.php
@@ -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)
diff --git a/packages/ep_products/Classes/Middleware/StaticSearchParamsMiddleware.php b/packages/ep_products/Classes/Middleware/StaticSearchParamsMiddleware.php
new file mode 100644
index 0000000..816d8fd
--- /dev/null
+++ b/packages/ep_products/Classes/Middleware/StaticSearchParamsMiddleware.php
@@ -0,0 +1,71 @@
+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);
+ }
+}
diff --git a/packages/ep_products/Classes/Service/FilterService.php b/packages/ep_products/Classes/Service/FilterService.php
index ffbfba6..1b44591 100644
--- a/packages/ep_products/Classes/Service/FilterService.php
+++ b/packages/ep_products/Classes/Service/FilterService.php
@@ -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;
}
diff --git a/packages/ep_products/Classes/Service/SearchResultService.php b/packages/ep_products/Classes/Service/SearchResultService.php
index 768097c..268cfe2 100644
--- a/packages/ep_products/Classes/Service/SearchResultService.php
+++ b/packages/ep_products/Classes/Service/SearchResultService.php
@@ -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']);
});
}
diff --git a/packages/ep_products/Classes/Traits/RequestArgumentTypeConversionTrait.php b/packages/ep_products/Classes/Traits/RequestArgumentTypeConversionTrait.php
index 9104d9b..d03fbbb 100644
--- a/packages/ep_products/Classes/Traits/RequestArgumentTypeConversionTrait.php
+++ b/packages/ep_products/Classes/Traits/RequestArgumentTypeConversionTrait.php
@@ -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);
}
}
diff --git a/packages/ep_products/Configuration/RequestMiddlewares.php b/packages/ep_products/Configuration/RequestMiddlewares.php
index 7d8d1b4..73016e7 100644
--- a/packages/ep_products/Configuration/RequestMiddlewares.php
+++ b/packages/ep_products/Configuration/RequestMiddlewares.php
@@ -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' => [
diff --git a/packages/ep_products/Configuration/TCA/Overrides/tt_content.php b/packages/ep_products/Configuration/TCA/Overrides/tt_content.php
index 8bad90d..e93d057 100644
--- a/packages/ep_products/Configuration/TCA/Overrides/tt_content.php
+++ b/packages/ep_products/Configuration/TCA/Overrides/tt_content.php
@@ -7,6 +7,12 @@ defined('TYPO3') or die();
call_user_func(function () {
+ ExtensionUtility::registerPlugin(
+ 'EpProducts',
+ 'searchresult',
+ 'Suchergebnis'
+ );
+
ExtensionUtility::registerPlugin(
'EpProducts',
'product_detail',
diff --git a/packages/ep_products/Configuration/TCA/tx_epproducts_domain_model_staticsearchparams.php b/packages/ep_products/Configuration/TCA/tx_epproducts_domain_model_staticsearchparams.php
new file mode 100644
index 0000000..f5c0aa7
--- /dev/null
+++ b/packages/ep_products/Configuration/TCA/tx_epproducts_domain_model_staticsearchparams.php
@@ -0,0 +1,243 @@
+ [
+ '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,
+ ]
+ ]
+ ],
+];
diff --git a/packages/ep_products/ext_localconf.php b/packages/ep_products/ext_localconf.php
index a699a50..72d509a 100644
--- a/packages/ep_products/ext_localconf.php
+++ b/packages/ep_products/ext_localconf.php
@@ -1,6 +1,9 @@
'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',
]
diff --git a/packages/ep_theme/Classes/ViewHelpers/PriceRangeViewHelper.php b/packages/ep_theme/Classes/ViewHelpers/PriceRangeViewHelper.php
new file mode 100644
index 0000000..79f4473
--- /dev/null
+++ b/packages/ep_theme/Classes/ViewHelpers/PriceRangeViewHelper.php
@@ -0,0 +1,59 @@
+, 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']);
+ }
+}
diff --git a/packages/ep_theme/Resources/Private/Assets/js/controllers/mobile_search_controller.js b/packages/ep_theme/Resources/Private/Assets/js/controllers/mobile_search_controller.js
index aea506f..4ef29b5 100644
--- a/packages/ep_theme/Resources/Private/Assets/js/controllers/mobile_search_controller.js
+++ b/packages/ep_theme/Resources/Private/Assets/js/controllers/mobile_search_controller.js
@@ -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))
})
}
diff --git a/packages/ep_theme/Resources/Private/Assets/js/controllers/search_controller.js b/packages/ep_theme/Resources/Private/Assets/js/controllers/search_controller.js
index 8132fb1..212ea2f 100644
--- a/packages/ep_theme/Resources/Private/Assets/js/controllers/search_controller.js
+++ b/packages/ep_theme/Resources/Private/Assets/js/controllers/search_controller.js
@@ -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))
})
}
diff --git a/packages/ep_theme/Resources/Private/Layouts/Page/Detail.html b/packages/ep_theme/Resources/Private/Layouts/Page/Detail.html
index f4a364e..64be84e 100644
--- a/packages/ep_theme/Resources/Private/Layouts/Page/Detail.html
+++ b/packages/ep_theme/Resources/Private/Layouts/Page/Detail.html
@@ -14,6 +14,7 @@