feat: consolidate event search teasers

addresses #869e4n8py
This commit is contained in:
Björn Fromme
2026-07-14 16:29:21 +02:00
parent 12bcb9360c
commit 9c5a6747bc
4 changed files with 135 additions and 33 deletions
@@ -171,9 +171,9 @@ class FilterService implements SingletonInterface
$dateMin = $item['minDateStart'];
}
if (null === $dateMax || $item['maxDateEnd'] > $dateMax) {
$dateMax = $row['itemMaxDateEnd'];
$dateMax = $item['maxDateEnd'];
}
if ((bool) $row['busAvailable'] === true) {
if ((bool) $item['busAvailable'] === true) {
$busAvailable = true;
}
foreach ($item['nights'] as $nightsOption) {
@@ -182,36 +182,47 @@ class FilterService implements SingletonInterface
$nights[] = $duration;
}
}
if (!array_key_exists($item['country']['uid'], $destinations)) {
$destinations[$item['country']['uid']] = [
$itemDestinations = $item['filterMetadata']['destinations'] ?? [[
'country' => $item['country'],
'region' => $item['region'],
]];
foreach ($itemDestinations as $itemDestination) {
$country = $itemDestination['country'];
$region = $itemDestination['region'];
if (!array_key_exists($country['uid'], $destinations)) {
$destinations[$country['uid']] = [
'country' => [
'uid' => $item['country']['uid'],
'label' => $item['country']['name'],
'code' => $item['country']['code'],
'uid' => $country['uid'],
'label' => $country['name'],
'code' => $country['code'],
],
'regions' => [],
];
];
}
if (!array_key_exists($region['uid'], $destinations[$country['uid']]['regions'])) {
$destinations[$country['uid']]['regions'][$region['uid']] = [
'uid' => $region['uid'],
'label' => $region['name'],
];
}
}
if (!array_key_exists($item['region']['uid'], $destinations[$item['country']['uid']]['regions'])) {
$destinations[$item['country']['uid']]['regions'][$item['region']['uid']] = [
'uid' => $item['region']['uid'],
'label' => $item['region']['name'],
];
}
if (!\in_array($item['hotel']['type'], $hotelTypes, false)) {
$hotelTypes[] = $item['hotel']['type'];
foreach ($item['filterMetadata']['hotelTypes'] ?? [$item['hotel']['type']] as $hotelType) {
if (!\in_array($hotelType, $hotelTypes, false)) {
$hotelTypes[] = $hotelType;
}
}
foreach ($item['rooms'] as $roomType) {
if (!\in_array($roomType, $roomTypes, false)) {
$roomTypes[] = $roomType;
}
}
$boardType = [
foreach ($item['filterMetadata']['boardTypes'] ?? [[
'uid' => $item['boardUid'],
'label' => $item['board'],
];
if (!\in_array($boardType, $boardTypes, false)) {
$boardTypes[] = $boardType;
]] as $boardType) {
if (!\in_array($boardType, $boardTypes, false)) {
$boardTypes[] = $boardType;
}
}
foreach (FilterOptions::$priceRangeOptions as $index => $priceRange) {
if (array_key_exists($index, $priceRanges)) {
@@ -49,14 +49,25 @@ class SearchResultService implements SingletonInterface
*/
protected $hotelImageService;
/**
* @var ProductImageService
*/
protected $productImageService;
/**
* @param ProductRepository $productRepository
* @param HotelImageService $hotelImageService
* @param ProductImageService $productImageService
*/
public function __construct(ProductRepository $productRepository, HotelImageService $hotelImageService)
public function __construct(
ProductRepository $productRepository,
HotelImageService $hotelImageService,
ProductImageService $productImageService
)
{
$this->productRepository = $productRepository;
$this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService;
}
public function getNormalizedSearchParams(array $searchParams = []): array
@@ -80,10 +91,10 @@ class SearchResultService implements SingletonInterface
{
$searchResultData = $this->productRepository->getSearchResult($filterSettings, $excludedConceptUids);
return $this->preprocess($searchResultData);
return $this->preprocess($searchResultData, true);
}
public function preprocess(array $searchResultData): SearchResult
public function preprocess(array $searchResultData, bool $aggregateEvents = false): SearchResult
{
$data = [];
$total = 0;
@@ -105,8 +116,13 @@ class SearchResultService implements SingletonInterface
$items =& $data[$row['conceptUid']]['items'];
// Use compound key, hotel is not unique
$key = $row['hotelUid'] . ':' . $row['productUid'];
$isAggregatedEvent = $aggregateEvents && 'evt' === $row['conceptCode'];
// Events are product-level search results. Other results (including
// watchlist items) remain tied to a particular hotel.
$key = $isAggregatedEvent
? (string) $row['productUid']
: $row['hotelUid'] . ':' . $row['productUid'];
$hotelCategory = Hotel::$categoryLabels[$row['hotelCategory']] ?? null;
@@ -122,6 +138,7 @@ class SearchResultService implements SingletonInterface
'season' => $row['season'],
'hideBookingButton' => $row['hideBookingButton'],
'busAvailable' => $row['busAvailable'],
'available' => $row['available'],
'lowContingent' => $row['lowContingent'],
'servicesIncluded' => unserialize($row['servicesIncluded'], ['allowed_classes' => false]),
'hotel' => [
@@ -151,21 +168,55 @@ class SearchResultService implements SingletonInterface
'subline' => $row['productSubline'],
'feature' => $row['productFeature'],
'fact' => $row['productFact'],
'images' => $isAggregatedEvent
? $this->productImageService->getImages($row['productUid'])
: [],
],
'board' => $row['board'],
'boardUid' => $row['boardUid'],
'dates' => [],
'nights' => [],
'teaserNights' => [],
'rooms' => [],
'new' => $row['new'],
'skiPassIncluded' => $row['skiPassIncluded'],
'filterMetadata' => [
'destinations' => [],
'hotelTypes' => [],
'boardTypes' => [],
],
];
$total++;
}
$item =& $items[$key];
if (!\in_array($row['roomType'], $item['rooms'], false)) {
$destination = [
'country' => [
'uid' => $row['countryUid'],
'code' => $row['countryCode'],
'name' => $row['countryName'],
],
'region' => [
'uid' => $row['regionUid'],
'name' => $row['regionName'],
'fact' => $row['regionFact'],
'feature' => $row['regionFeature'],
],
];
if (!\in_array($destination, $item['filterMetadata']['destinations'], false)) {
$item['filterMetadata']['destinations'][] = $destination;
}
$hotelType = $row['hotelType'] ?? $item['hotel']['type'];
if (!\in_array($hotelType, $item['filterMetadata']['hotelTypes'], false)) {
$item['filterMetadata']['hotelTypes'][] = $hotelType;
}
$boardType = ['uid' => $row['boardUid'] ?? null, 'label' => $row['board']];
if (!\in_array($boardType, $item['filterMetadata']['boardTypes'], false)) {
$item['filterMetadata']['boardTypes'][] = $boardType;
}
if (isset($row['roomType']) && !\in_array($row['roomType'], $item['rooms'], false)) {
$item['rooms'][] = $row['roomType'];
}
@@ -193,8 +244,25 @@ class SearchResultService implements SingletonInterface
if ($item['minPrice'] === null
|| $item['minPrice'] > $row['minPrice']) {
$item['minPrice'] = $row['minPrice'];
// Teaser-specific offer data must describe the cheapest row.
$item['teaserNights'] = [$row['nights']];
$item['board'] = $row['board'];
$item['boardUid'] = $row['boardUid'];
$item['daytrip'] = $row['daytrip'];
$item['season'] = $row['season'];
$item['hideBookingButton'] = $row['hideBookingButton'];
$item['servicesIncluded'] = unserialize($row['servicesIncluded'], ['allowed_classes' => false]);
$item['new'] = $row['new'];
$item['skiPassIncluded'] = $row['skiPassIncluded'];
}
// Availability indicators describe the consolidated set, not only
// the row that supplies the teaser price.
$item['earlybird'] = (bool) $item['earlybird'] || (bool) $row['earlybird'];
$item['busAvailable'] = (bool) $item['busAvailable'] || (bool) $row['busAvailable'];
$item['lowContingent'] = (bool) $item['lowContingent'] || (bool) $row['lowContingent'];
$item['available'] = (bool) $item['available'] || (bool) $row['available'];
}
// Sort products by minprice within concept sections except for 'event' concept
@@ -38,6 +38,8 @@
</f:if>
<span class="product-teaser__label-concept">{concept.name}</span>
</div>
<f:if condition="{hideWatchlist}">
<f:else>
<button type="button"
data-controller="watchlist-toggle"
data-watchlist-toggle-key-value="{hotel.uid}:{product.uid}"
@@ -47,6 +49,8 @@
aria-label="auf die Merkliste setzen">
<svg class="product-teaser__watchlist-icon" data-watchlist-toggle-target="icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path></svg>
</button>
</f:else>
</f:if>
</div>
<f:if condition="{new}">
<div class="absolute top-12 left-2 text-white font-bold text-sm leading-none p-1 bg-badge-gradient">
@@ -64,24 +64,43 @@
</button>
</div>
<div class="grid grid-cols-2 xl:grid-cols-3 gap-4 md:gap-8"
data-teasergrid-target="container">
data-teasergrid-target="container">
<f:for each="{section.items}" as="item">
<f:if condition="{section.concept.code} == 'evt'">
<f:then>
<f:variable name="searchProductImage" value="{item.product.images.original.0}"/>
<f:variable name="searchForceHotelUid" value="0"/>
<f:variable name="searchShowHotelDetails" value="0"/>
<f:variable name="searchHideWatchlist" value="1"/>
<f:variable name="searchDateFrom" value=""/>
<f:variable name="searchDateTo" value=""/>
</f:then>
<f:else>
<f:variable name="searchProductImage" value="{item.hotel.images.original.0}"/>
<f:variable name="searchForceHotelUid" value="1"/>
<f:variable name="searchShowHotelDetails" value="1"/>
<f:variable name="searchHideWatchlist" value="0"/>
<f:variable name="searchDateFrom" value="{item.minDateStart}"/>
<f:variable name="searchDateTo" value="{item.maxDateEnd}"/>
</f:else>
</f:if>
<f:render partial="Product/Teaser" section="Teaser" arguments="{
concept: section.concept,
hotel: item.hotel,
product: item.product,
country: item.country,
region: item.region,
productImage: item.hotel.images.original.0,
nights: item.nights,
productImage: searchProductImage,
nights: item.teaserNights,
board: item.board,
minPrice: item.minPrice,
daytrip: item.daytrip,
season: item.season,
dateFrom: item.minDateStart,
dateTo: item.maxDateEnd,
forceHotelUid: 1,
showHotelDetails: 1,
dateFrom: searchDateFrom,
dateTo: searchDateTo,
forceHotelUid: searchForceHotelUid,
showHotelDetails: searchShowHotelDetails,
hideWatchlist: searchHideWatchlist,
new: item.new,
skiPassIncluded: item.skiPassIncluded,
referringPageUri: referringPageUri