feat: display pickups data on product detail page

addresses #869cgn2fm
This commit is contained in:
fromme committed 2026-10-07 17:01:13 +02:00
1 parent c6ab02305b
commit 41d188a8cc
6 files changed
+210 -5

No files matched your search

@@ -36,6 +36,7 @@ use EP\EpProducts\Service\ContingentDataService;
use EP\EpProducts\Service\DateService;
use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\HotelDataService;
use EP\EpProducts\Service\PickupOverviewService;
use EP\EpProducts\Service\ProductDataService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
@@ -82,6 +83,11 @@ class ProductController extends ActionController
*/
private $infoboxRepository;
/**
* @var PickupOverviewService
*/
private $pickupOverviewService;
/**
* @param ProductRepository $productRepository
* @param ProductDataService $productDataService
@@ -91,6 +97,7 @@ class ProductController extends ActionController
* @param DateService $dateService
* @param FilterService $filterService
* @param InfoboxRepository $infoboxRepository
* @param PickupOverviewService $pickupOverviewService
*/
public function __construct(
ProductRepository $productRepository,
@@ -100,7 +107,8 @@ class ProductController extends ActionController
ContingentDataService $contingentDataService,
DateService $dateService,
FilterService $filterService,
InfoboxRepository $infoboxRepository
InfoboxRepository $infoboxRepository,
PickupOverviewService $pickupOverviewService
) {
$this->productRepository = $productRepository;
$this->productDataService = $productDataService;
@@ -110,6 +118,7 @@ class ProductController extends ActionController
$this->dateService = $dateService;
$this->filterService = $filterService;
$this->infoboxRepository = $infoboxRepository;
$this->pickupOverviewService = $pickupOverviewService;
}
/**
@@ -196,6 +205,7 @@ class ProductController extends ActionController
'referringUrl' => $backlinkUrl,
'backlinkLabel' => $backLinkLabel,
'nonBookableInfo' => $nonBookableInfo,
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
]);
}
@@ -3,6 +3,7 @@
namespace EP\EpProducts\Domain\Repository;
use EP\EpProducts\Domain\Model\Product;
use TYPO3\CMS\Core\Database\Connection;
class DateRepository extends AbstractRepository
{
@@ -18,4 +19,26 @@ class DateRepository extends AbstractRepository
->execute()
;
}
/**
* @return string[] serialized pickups of each upcoming date, latest first
*/
public function findPickupsForProductAndHotel(int $productUid, int $hotelUid): array
{
$qb = $this->getDbConnection()->createQueryBuilder();
return $qb
->select('date.pickups')
->from('tx_epproducts_domain_model_date', 'date')
->where(
$qb->expr()->eq('date.product', $qb->createNamedParameter($productUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.hotel', $qb->createNamedParameter($hotelUid, Connection::PARAM_INT)),
'date.date_start >= NOW()'
)
->orderBy('date.date_start', 'DESC')
->addOrderBy('date.uid', 'DESC')
->execute()
->fetchFirstColumn()
;
}
}
@@ -0,0 +1,80 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\Domain\Model\Product;
use EP\EpProducts\Domain\Repository\DateRepository;
/**
* Lists the pickup points of a product's latest upcoming date that has any,
* with that date's time and round-trip surcharge per pickup point. Pickups,
* times and prices may differ on other dates; taking the latest one keeps the
* values unique and shows them for as long as the product is offered.
*
* The returned shape is meant to be served by MyE&P later on, so that only the
* data source has to change once the local date table goes away.
*/
class PickupOverviewService
{
/**
* @var DateRepository
*/
private $dateRepository;
public function __construct(DateRepository $dateRepository)
{
$this->dateRepository = $dateRepository;
}
/**
* @return array{pickups: array<int, array{busProId: int, city: string, street: string, price: float, time: string}>}
*/
public function getOverview(Product $product, ?Hotel $hotel): array
{
$overview = ['pickups' => []];
if ($hotel === null || $product->isDaytrip()) {
return $overview;
}
$dates = $this->dateRepository->findPickupsForProductAndHotel((int) $product->getUid(), (int) $hotel->getUid());
foreach ($dates as $serializedPickups) {
$pickups = $this->readPickups((string) $serializedPickups);
if ($pickups !== []) {
$overview['pickups'] = $pickups;
break;
}
}
return $overview;
}
/**
* Keeps the order of the import, which is the boarding order.
*/
private function readPickups(string $serializedPickups): array
{
$datePickups = @unserialize($serializedPickups, ['allowed_classes' => false]);
if (!is_array($datePickups)) {
return [];
}
$pickups = [];
foreach ($datePickups as $pickup) {
$busProId = (int) ($pickup['bus_pro_id'] ?? 0);
if ($busProId === 0 || isset($pickups[$busProId])) {
continue;
}
$pickups[$busProId] = [
'busProId' => $busProId,
'city' => (string) ($pickup['city'] ?? ''),
'street' => (string) ($pickup['street'] ?? ''),
// A pickup's price is one leg; the surcharge is split evenly between out- and inbound.
'price' => 2 * (float) ($pickup['price'] ?? 0.0),
'time' => (string) ($pickup['time'] ?? ''),
];
}
return array_values($pickups);
}
}
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = [ 'sizer', 'container', 'button' ]
static targets = [ 'sizer', 'container', 'button', 'bar' ]
static values = {
offset: {
@@ -33,6 +33,12 @@ export default class extends Controller {
// Set initial expanded/collapsed status and button visibility
this.expandedValue = this.contentHeight < this.maxHeight
this.buttonTarget.classList.toggle('hidden', this.expandedValue)
// Drop the space reserved for the toggle bar when the content fits anyway
if (this.expandedValue && this.hasBarTarget) {
this.barTarget.classList.add('hidden')
this.containerTarget.style.paddingBottom = '0'
}
}
toggle() {
@@ -0,0 +1,81 @@
<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="group odd:bg-zinc-50 p-4" data-rte-content>
<h3>
Bus-Zustiege
</h3>
<f:if condition="{pickupsOverview.pickups -> f:count()} <= 3">
<f:then>
<f:render section="Table" arguments="{_all}"/>
</f:then>
<f:else>
<div data-controller="autocollapse" data-autocollapse-height-value="400" data-autocollapse-offset-value="64">
<div class="relative pb-12" data-autocollapse-target="container">
<f:render section="Table" arguments="{_all}"/>
<div class="absolute left-0 bottom-0 w-full h-12 bg-white group-odd:bg-zinc-50 flex items-center" data-autocollapse-target="bar">
<button type="button"
data-autocollapse-target="button"
data-action="autocollapse#toggle"
class="hidden text-ep-primary w-10 h-10 ml-2 group toggle-more">
<f:render section="ToggleIcon"/>
</button>
</div>
</div>
</div>
</f:else>
</f:if>
<p class="text-sm">
Abfahrtszeiten und -orte unter Vorbehalt. Die genauen Zeiten werden euch kurz vor der Anreise mitgeteilt.
</p>
</div>
<f:section name="Table">
<table class="w-full bg-zinc-200 text-zinc-800 text-xs md:text-base mb-4">
<tr>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Zustieg
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Abfahrt
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Zuschlag
</th>
</tr>
<f:for each="{pickupsOverview.pickups}" as="pickup">
<tr class="odd:bg-zinc-50 align-top">
<td class="border-r border-zinc-200 px-2 py-1 md:p-2">
{pickup.city}<br><span class="text-xs">{pickup.street}</span>
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 whitespace-nowrap">
<f:if condition="{pickup.time}">
<f:then>{pickup.time} Uhr</f:then>
<f:else>-</f:else>
</f:if>
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 whitespace-nowrap">
<f:if condition="{pickup.price} > 0">
<f:then>{pickup.price -> f:format.currency(currencySign: '€', decimalSeparator: ',', thousandsSeparator: '.')}</f:then>
<f:else>-</f:else>
</f:if>
</td>
</tr>
</f:for>
</table>
</f:section>
<f:section name="ToggleIcon">
<svg viewBox="0 0 184 185" xmlns="http://www.w3.org/2000/svg" stroke="none" fill="currentColor">
<g>
<path d="M92,0C41.2,0 0,41.2 0,92.1C0,143 41.2,184.1 92,184.1C142.8,184.1 184,142.9 184,92.1C184,41.3 142.8,0 92,0ZM92,173.1C47.3,173.1 11,136.8 11,92.1C11,47.4 47.3,11.1 92,11.1C136.7,11.1 173,47.4 173,92.1C173,136.8 136.7,173.1 92,173.1Z"/>
<path d="M134.2,92.1C134.2,93.6 133.7,95 132.6,96C131.5,97.1 130.3,97.6 128.7,97.6L55.3,97.6C53.8,97.6 52.4,97.1 51.4,96C50.3,94.9 49.8,93.7 49.8,92.1C49.8,90.5 50.3,89.2 51.4,88.2C52.5,87.1 53.7,86.6 55.3,86.6L128.6,86.6C130.1,86.6 131.5,87.1 132.5,88.2C133.6,89.3 134.1,90.5 134.1,92.1"/>
</g>
<g class="group-[.toggle-less]:hidden">
<path d="M97,128.7C97,130.2 96.5,131.6 95.4,132.6C94.3,133.7 93.1,134.2 91.5,134.2C89.9,134.2 88.6,133.7 87.6,132.6C86.5,131.5 86,130.3 86,128.7L86,55.4C86,53.9 86.5,52.5 87.6,51.5C88.7,50.4 89.9,49.9 91.5,49.9C93.1,49.9 94.4,50.4 95.4,51.5C96.5,52.6 97,53.8 97,55.4L97,128.7Z"/>
</g>
</svg>
</f:section>
</html>
@@ -42,7 +42,7 @@
<f:render section="TabButton" arguments="{tab: 'hotel', label: 'Unterkunft', wide: 0}"/>
<f:render section="TabButton" arguments="{tab: 'region', label: 'Gebiet', wide: 0}"/>
<f:render section="TabButton" arguments="{tab: 'city', label: 'Ort', wide: 0}"/>
<f:if condition="{product.journey}">
<f:if condition="{product.journey} || {pickupsOverview.pickups}">
<f:render section="TabButton" arguments="{tab: 'journey', label: 'Anreise', wide: 0}"/>
</f:if>
</div>
@@ -158,11 +158,16 @@
</f:section>
<f:section name="Journey">
<f:if condition="{product.journey}">
<f:if condition="{product.journey} || {pickupsOverview.pickups}">
<div class="mb-4 hidden"
data-tabs-target="tab"
data-tab="journey">
<f:render partial="Journey/Details" arguments="{journey: product.journey, bars: 1}"/>
<f:if condition="{product.journey}">
<f:render partial="Journey/Details" arguments="{journey: product.journey, bars: 1}"/>
</f:if>
<f:if condition="{pickupsOverview.pickups}">
<f:render partial="Product/Pickups" arguments="{pickupsOverview: pickupsOverview}"/>
</f:if>
</div>
</f:if>
</f:section>