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']; $dateMin = $item['minDateStart'];
} }
if (null === $dateMax || $item['maxDateEnd'] > $dateMax) { if (null === $dateMax || $item['maxDateEnd'] > $dateMax) {
$dateMax = $row['itemMaxDateEnd']; $dateMax = $item['maxDateEnd'];
} }
if ((bool) $row['busAvailable'] === true) { if ((bool) $item['busAvailable'] === true) {
$busAvailable = true; $busAvailable = true;
} }
foreach ($item['nights'] as $nightsOption) { foreach ($item['nights'] as $nightsOption) {
@@ -182,36 +182,47 @@ class FilterService implements SingletonInterface
$nights[] = $duration; $nights[] = $duration;
} }
} }
if (!array_key_exists($item['country']['uid'], $destinations)) { $itemDestinations = $item['filterMetadata']['destinations'] ?? [[
$destinations[$item['country']['uid']] = [ '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' => [ 'country' => [
'uid' => $item['country']['uid'], 'uid' => $country['uid'],
'label' => $item['country']['name'], 'label' => $country['name'],
'code' => $item['country']['code'], 'code' => $country['code'],
], ],
'regions' => [], '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'])) { foreach ($item['filterMetadata']['hotelTypes'] ?? [$item['hotel']['type']] as $hotelType) {
$destinations[$item['country']['uid']]['regions'][$item['region']['uid']] = [ if (!\in_array($hotelType, $hotelTypes, false)) {
'uid' => $item['region']['uid'], $hotelTypes[] = $hotelType;
'label' => $item['region']['name'], }
];
}
if (!\in_array($item['hotel']['type'], $hotelTypes, false)) {
$hotelTypes[] = $item['hotel']['type'];
} }
foreach ($item['rooms'] as $roomType) { foreach ($item['rooms'] as $roomType) {
if (!\in_array($roomType, $roomTypes, false)) { if (!\in_array($roomType, $roomTypes, false)) {
$roomTypes[] = $roomType; $roomTypes[] = $roomType;
} }
} }
$boardType = [ foreach ($item['filterMetadata']['boardTypes'] ?? [[
'uid' => $item['boardUid'], 'uid' => $item['boardUid'],
'label' => $item['board'], 'label' => $item['board'],
]; ]] as $boardType) {
if (!\in_array($boardType, $boardTypes, false)) { if (!\in_array($boardType, $boardTypes, false)) {
$boardTypes[] = $boardType; $boardTypes[] = $boardType;
}
} }
foreach (FilterOptions::$priceRangeOptions as $index => $priceRange) { foreach (FilterOptions::$priceRangeOptions as $index => $priceRange) {
if (array_key_exists($index, $priceRanges)) { if (array_key_exists($index, $priceRanges)) {
@@ -49,14 +49,25 @@ class SearchResultService implements SingletonInterface
*/ */
protected $hotelImageService; protected $hotelImageService;
/**
* @var ProductImageService
*/
protected $productImageService;
/** /**
* @param ProductRepository $productRepository * @param ProductRepository $productRepository
* @param HotelImageService $hotelImageService * @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->productRepository = $productRepository;
$this->hotelImageService = $hotelImageService; $this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService;
} }
public function getNormalizedSearchParams(array $searchParams = []): array public function getNormalizedSearchParams(array $searchParams = []): array
@@ -80,10 +91,10 @@ class SearchResultService implements SingletonInterface
{ {
$searchResultData = $this->productRepository->getSearchResult($filterSettings, $excludedConceptUids); $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 = []; $data = [];
$total = 0; $total = 0;
@@ -105,8 +116,13 @@ class SearchResultService implements SingletonInterface
$items =& $data[$row['conceptUid']]['items']; $items =& $data[$row['conceptUid']]['items'];
// Use compound key, hotel is not unique $isAggregatedEvent = $aggregateEvents && 'evt' === $row['conceptCode'];
$key = $row['hotelUid'] . ':' . $row['productUid'];
// 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; $hotelCategory = Hotel::$categoryLabels[$row['hotelCategory']] ?? null;
@@ -122,6 +138,7 @@ class SearchResultService implements SingletonInterface
'season' => $row['season'], 'season' => $row['season'],
'hideBookingButton' => $row['hideBookingButton'], 'hideBookingButton' => $row['hideBookingButton'],
'busAvailable' => $row['busAvailable'], 'busAvailable' => $row['busAvailable'],
'available' => $row['available'],
'lowContingent' => $row['lowContingent'], 'lowContingent' => $row['lowContingent'],
'servicesIncluded' => unserialize($row['servicesIncluded'], ['allowed_classes' => false]), 'servicesIncluded' => unserialize($row['servicesIncluded'], ['allowed_classes' => false]),
'hotel' => [ 'hotel' => [
@@ -151,21 +168,55 @@ class SearchResultService implements SingletonInterface
'subline' => $row['productSubline'], 'subline' => $row['productSubline'],
'feature' => $row['productFeature'], 'feature' => $row['productFeature'],
'fact' => $row['productFact'], 'fact' => $row['productFact'],
'images' => $isAggregatedEvent
? $this->productImageService->getImages($row['productUid'])
: [],
], ],
'board' => $row['board'], 'board' => $row['board'],
'boardUid' => $row['boardUid'], 'boardUid' => $row['boardUid'],
'dates' => [], 'dates' => [],
'nights' => [], 'nights' => [],
'teaserNights' => [],
'rooms' => [], 'rooms' => [],
'new' => $row['new'], 'new' => $row['new'],
'skiPassIncluded' => $row['skiPassIncluded'], 'skiPassIncluded' => $row['skiPassIncluded'],
'filterMetadata' => [
'destinations' => [],
'hotelTypes' => [],
'boardTypes' => [],
],
]; ];
$total++; $total++;
} }
$item =& $items[$key]; $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']; $item['rooms'][] = $row['roomType'];
} }
@@ -193,8 +244,25 @@ class SearchResultService implements SingletonInterface
if ($item['minPrice'] === null if ($item['minPrice'] === null
|| $item['minPrice'] > $row['minPrice']) { || $item['minPrice'] > $row['minPrice']) {
$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 // Sort products by minprice within concept sections except for 'event' concept
@@ -38,6 +38,8 @@
</f:if> </f:if>
<span class="product-teaser__label-concept">{concept.name}</span> <span class="product-teaser__label-concept">{concept.name}</span>
</div> </div>
<f:if condition="{hideWatchlist}">
<f:else>
<button type="button" <button type="button"
data-controller="watchlist-toggle" data-controller="watchlist-toggle"
data-watchlist-toggle-key-value="{hotel.uid}:{product.uid}" data-watchlist-toggle-key-value="{hotel.uid}:{product.uid}"
@@ -47,6 +49,8 @@
aria-label="auf die Merkliste setzen"> 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> <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> </button>
</f:else>
</f:if>
</div> </div>
<f:if condition="{new}"> <f:if condition="{new}">
<div class="absolute top-12 left-2 text-white font-bold text-sm leading-none p-1 bg-badge-gradient"> <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> </button>
</div> </div>
<div class="grid grid-cols-2 xl:grid-cols-3 gap-4 md:gap-8" <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: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="{ <f:render partial="Product/Teaser" section="Teaser" arguments="{
concept: section.concept, concept: section.concept,
hotel: item.hotel, hotel: item.hotel,
product: item.product, product: item.product,
country: item.country, country: item.country,
region: item.region, region: item.region,
productImage: item.hotel.images.original.0, productImage: searchProductImage,
nights: item.nights, nights: item.teaserNights,
board: item.board, board: item.board,
minPrice: item.minPrice, minPrice: item.minPrice,
daytrip: item.daytrip, daytrip: item.daytrip,
season: item.season, season: item.season,
dateFrom: item.minDateStart, dateFrom: searchDateFrom,
dateTo: item.maxDateEnd, dateTo: searchDateTo,
forceHotelUid: 1, forceHotelUid: searchForceHotelUid,
showHotelDetails: 1, showHotelDetails: searchShowHotelDetails,
hideWatchlist: searchHideWatchlist,
new: item.new, new: item.new,
skiPassIncluded: item.skiPassIncluded, skiPassIncluded: item.skiPassIncluded,
referringPageUri: referringPageUri referringPageUri: referringPageUri