Compare commits
3
Commits
14adc32bb8
...
25789612bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25789612bf | ||
|
|
9c5a6747bc | ||
|
|
12bcb9360c |
No files matched your search
@@ -6,7 +6,7 @@ GET https://ep-reisen.ddev.site/api/product
|
||||
|
||||
### GET hotel data for myep
|
||||
GET https://ep-reisen.ddev.site/api/hotel
|
||||
?hotel=SSTGR
|
||||
?hotel=DPWGRP
|
||||
&key=abcabc123
|
||||
|
||||
###
|
||||
Generated
-16
@@ -9197,22 +9197,6 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"extraneous": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
|
||||
@@ -27,8 +27,10 @@ namespace EP\EpProducts\Controller;
|
||||
* This copyright notice MUST APPEAR in all copies of the script!
|
||||
***************************************************************/
|
||||
|
||||
use EP\EpProducts\BpnConnect\ApiClient;
|
||||
use EP\EpProducts\BpnConnect\ApiClient as BpnConnectClient;
|
||||
use EP\EpProducts\MyEP\ApiClient as MyEpClient;
|
||||
use EP\EpProducts\Domain\Model\Hotel;
|
||||
use EP\EpProducts\MyEP\ApiException;
|
||||
use EP\EpProducts\Utility\DateUtility;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
|
||||
@@ -37,26 +39,147 @@ class AjaxCalendarController extends ActionController
|
||||
{
|
||||
private const CACHE_TTL = 3600;
|
||||
|
||||
/**
|
||||
* @var ApiClient
|
||||
*/
|
||||
protected $apiClient;
|
||||
protected BpnConnectClient $bpnConnectClient;
|
||||
protected MyEpClient $myEpClient;
|
||||
protected FrontendInterface $cache;
|
||||
|
||||
/**
|
||||
* @var FrontendInterface
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* @param ApiClient $apiClient
|
||||
* @param FrontendInterface $cache
|
||||
*/
|
||||
public function __construct(ApiClient $apiClient, FrontendInterface $cache)
|
||||
public function __construct(BpnConnectClient $bpnConnectClient, MyEpClient $myEpClient, FrontendInterface $cache)
|
||||
{
|
||||
$this->apiClient = $apiClient;
|
||||
$this->bpnConnectClient = $bpnConnectClient;
|
||||
$this->myEpClient = $myEpClient;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
public function priceCalendarAction(Hotel $hotel, int $months = 12): void
|
||||
{
|
||||
$hotelCode = trim($hotel->getCode() ?? '');
|
||||
$dateRange = DateUtility::getDateRange(null, null, $months);
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
$priceDataByDate = [];
|
||||
$error = false;
|
||||
|
||||
if ($hotelCode !== '') {
|
||||
try {
|
||||
$cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [
|
||||
$hotelCode,
|
||||
$dateRange['period']->getStartDate()->format('Y-m-d'),
|
||||
$dateRange['period']->getEndDate()->format('Y-m-d'),
|
||||
]));
|
||||
$cachedData = $this->cache->get($cacheIdentifier);
|
||||
|
||||
if (false !== $cachedData) {
|
||||
$priceDataByDate = $cachedData;
|
||||
} else {
|
||||
$rawData = $this->myEpClient->getPriceConfigData(
|
||||
$hotelCode,
|
||||
$dateRange['period']->getStartDate(),
|
||||
$dateRange['period']->getEndDate()
|
||||
);
|
||||
|
||||
$availableDates = [];
|
||||
foreach ($rawData as $item) {
|
||||
if (($item['status'] ?? '') === 'OK') {
|
||||
$availableDates[$item['date']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rawData as $item) {
|
||||
$date = $item['date'];
|
||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||
if ($dt < $today) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prevDate = $dt->modify('-1 day')->format('Y-m-d');
|
||||
$isAvailable = ($item['status'] ?? '') === 'OK';
|
||||
$prevAvailable = isset($availableDates[$prevDate]);
|
||||
|
||||
if ($isAvailable) {
|
||||
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
|
||||
} elseif ($prevAvailable) {
|
||||
$dayStatus = 'checkout-only';
|
||||
} else {
|
||||
$dayStatus = 'blocked';
|
||||
}
|
||||
|
||||
$priceDataByDate[$date] = [
|
||||
'status' => $dayStatus,
|
||||
'available' => $isAvailable ? 1 : 0,
|
||||
'tooltip' => $this->buildTooltip($dayStatus, $item),
|
||||
'pricePerNight' => $item['pricePerNight'] ?? null,
|
||||
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
|
||||
'type' => $item['type'] ?? null,
|
||||
'includedPax' => $item['includedPax'] ?? null,
|
||||
'minNights' => (int)($item['minNights'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL);
|
||||
}
|
||||
} catch (ApiException $e) {
|
||||
$error = true;
|
||||
}
|
||||
} else {
|
||||
$error = true;
|
||||
}
|
||||
|
||||
$this->view->assignMultiple([
|
||||
'priceDataByDate' => $priceDataByDate,
|
||||
'calendarMonths' => $this->buildCalendarMonths($dateRange['period']),
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
public function priceConfigAction(Hotel $hotel, string $year = null, string $month = null, int $months = 12)
|
||||
{
|
||||
$dateRange = DateUtility::getDateRange($year, $month, $months);
|
||||
|
||||
$apiDateFrom = $dateRange['period']->getStartDate();
|
||||
$apiDateTo = $dateRange['period']->getEndDate();
|
||||
$hotelCode = trim($hotel->getCode() ?? '');
|
||||
|
||||
$priceConfig = [
|
||||
'data' => [],
|
||||
'success' => true,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode, $apiDateFrom, $apiDateTo);
|
||||
} catch (ApiException $e) {
|
||||
$priceConfig['success'] = false;
|
||||
$priceConfig['error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$this->view->assign('priceConfig', $priceConfig);
|
||||
}
|
||||
|
||||
public function priceTableAction(Hotel $hotel, string $year = null): void
|
||||
{
|
||||
$year = $year ?? (new \DateTimeImmutable())->format('Y');
|
||||
$hotelCode = trim($hotel->getCode() ?? '');
|
||||
|
||||
$priceTable = [
|
||||
'data' => [],
|
||||
'success' => true,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$priceTable['data'] = $this->myEpClient->getPriceTableData($hotelCode, $year);
|
||||
} catch (ApiException $e) {
|
||||
$priceTable['success'] = false;
|
||||
$priceTable['error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$this->view->assignMultiple([
|
||||
'hotel' => $hotel,
|
||||
'year' => $year,
|
||||
'priceTable' => $priceTable,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Hotel $hotel
|
||||
* @param string $year
|
||||
@@ -91,7 +214,7 @@ class AjaxCalendarController extends ActionController
|
||||
if (false !== $cachedCalendar) {
|
||||
$calendar = $cachedCalendar;
|
||||
} else {
|
||||
$calendar = $this->apiClient->getCalendar(
|
||||
$calendar = $this->bpnConnectClient->getCalendar(
|
||||
$hotelCode,
|
||||
new \DateTimeImmutable($apiDateFrom),
|
||||
new \DateTimeImmutable($apiDateTo),
|
||||
@@ -112,6 +235,36 @@ class AjaxCalendarController extends ActionController
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildTooltip(string $status, array $item): string
|
||||
{
|
||||
if ($status === 'blocked') {
|
||||
return 'Nicht verfügbar';
|
||||
}
|
||||
if ($status === 'checkout-only') {
|
||||
return 'Nur Abreise';
|
||||
}
|
||||
return $this->buildPriceTooltip($item);
|
||||
}
|
||||
|
||||
private function buildPriceTooltip(array $item): string
|
||||
{
|
||||
$price = $item['pricePerNight'] ?? null;
|
||||
|
||||
if ($price !== null) {
|
||||
$priceStr = (($item['type'] ?? null) === 'discount')
|
||||
? $price . ' € (Angebot)'
|
||||
: 'ab ' . $price . ' €';
|
||||
} else {
|
||||
$priceStr = 'Anfrage';
|
||||
}
|
||||
|
||||
$minNights = (int)($item['minNights'] ?? 0);
|
||||
|
||||
return $minNights > 1
|
||||
? $priceStr . ' · min. ' . $minNights . ' Nächte'
|
||||
: $priceStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,8 @@ class ProductRepository extends AbstractRepository
|
||||
$qb
|
||||
->select(
|
||||
'date.concept as conceptUid', 'date.concept_name as conceptName',
|
||||
'date.concept_code as conceptCode', 'date.product as productUid', 'date.product_name as productName',
|
||||
'date.concept_code as conceptCode', 'con.popup_text as conceptPopupText',
|
||||
'date.product as productUid', 'date.product_name as productName',
|
||||
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
|
||||
'date.country as countryUid','date.country_name as countryName', 'date.country_code as countryCode',
|
||||
'date.region as regionUid', 'date.region_name as regionName', 'date.region_feature as regionFeature',
|
||||
@@ -30,6 +31,7 @@ class ProductRepository extends AbstractRepository
|
||||
'date.available as available', 'date.pseudo_price as pseudoPrice'
|
||||
)
|
||||
->from('tx_epproducts_domain_model_date', 'date')
|
||||
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
|
||||
->where($qb->expr()->in(
|
||||
'date.product',
|
||||
$qb->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY))
|
||||
@@ -60,7 +62,7 @@ class ProductRepository extends AbstractRepository
|
||||
->select(
|
||||
'date.concept as conceptUid', 'date.concept_name as conceptName',
|
||||
'date.concept_code as conceptCode', 'date.concept_sorting as conceptSorting',
|
||||
'date.concept_popup_text as conceptPopupText',
|
||||
'con.popup_text as conceptPopupText',
|
||||
'date.product as productUid', 'date.product_name as productName',
|
||||
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
|
||||
'date.daytrip as daytrip', 'date.country as countryUid','date.country_name as countryName',
|
||||
@@ -78,6 +80,7 @@ class ProductRepository extends AbstractRepository
|
||||
'room.type as roomType'
|
||||
)
|
||||
->from('tx_epproducts_domain_model_date', 'date')
|
||||
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
|
||||
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('date.product_searchable', 1),
|
||||
@@ -125,31 +128,35 @@ class ProductRepository extends AbstractRepository
|
||||
|
||||
$qb
|
||||
->select(
|
||||
'product as productUid', 'product_name as productName', 'product_detail_page as productDetailPage',
|
||||
'product_feature as productFeature', 'product_teaser as productTeaser', 'product_fact as productFact',
|
||||
'product_slug as productSlug',
|
||||
'hotel as hotelUid', 'hotel_fact as hotelFact', 'hotel_name as hotelName',
|
||||
'hotel_category as hotelCategory', 'region_fact as regionFact', 'region_name as regionName',
|
||||
'region_feature as regionFeature',
|
||||
'country_name as countryName', 'country_code as countryCode',
|
||||
'date_start as dateStart', 'date_end as dateEnd', 'uid as dateUid', 'min_price as minPrice',
|
||||
'pseudo_price as pseudoPrice', 'nights as nights', 'available as available', 'season as season',
|
||||
'skipass_included as skiPassIncluded', 'bus_included as busIncluded',
|
||||
'earlybird', 'new', 'daytrip', 'hide_booking_button as hideBookingButton',
|
||||
'low_contingent as lowContingent', 'concept as conceptUid', 'concept_code as conceptCode',
|
||||
'concept_name as conceptName', 'concept_popup_text as conceptPopupText', 'board', 'new'
|
||||
'date.product as productUid', 'date.product_name as productName',
|
||||
'date.product_detail_page as productDetailPage', 'date.product_feature as productFeature',
|
||||
'date.product_teaser as productTeaser', 'date.product_fact as productFact',
|
||||
'date.product_slug as productSlug',
|
||||
'date.hotel as hotelUid', 'date.hotel_fact as hotelFact', 'date.hotel_name as hotelName',
|
||||
'date.hotel_category as hotelCategory', 'date.region_fact as regionFact',
|
||||
'date.region_name as regionName', 'date.region_feature as regionFeature',
|
||||
'date.country_name as countryName', 'date.country_code as countryCode',
|
||||
'date.date_start as dateStart', 'date.date_end as dateEnd', 'date.uid as dateUid',
|
||||
'date.min_price as minPrice', 'date.pseudo_price as pseudoPrice',
|
||||
'date.nights as nights', 'date.available as available', 'date.season as season',
|
||||
'date.skipass_included as skiPassIncluded', 'date.bus_included as busIncluded',
|
||||
'date.earlybird', 'date.new', 'date.daytrip', 'date.hide_booking_button as hideBookingButton',
|
||||
'date.low_contingent as lowContingent', 'date.concept as conceptUid',
|
||||
'date.concept_code as conceptCode', 'date.concept_name as conceptName',
|
||||
'con.popup_text as conceptPopupText', 'date.board'
|
||||
)
|
||||
->from('tx_epproducts_domain_model_date', 'date')
|
||||
->where('date_start >= NOW()')
|
||||
->groupBy('product')
|
||||
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
|
||||
->where('date.date_start >= NOW()')
|
||||
->groupBy('date.product')
|
||||
->addGroupBy('date.hotel')
|
||||
->addGroupBy('date.date_start')
|
||||
->addGroupBy('date.nights')
|
||||
->having('available > 0')
|
||||
->orderBy('important', 'DESC')
|
||||
->addOrderBy('new', 'DESC')
|
||||
->addOrderBy('concept_sorting')
|
||||
->addOrderBy('date_start')
|
||||
->having('date.available > 0')
|
||||
->orderBy('date.important', 'DESC')
|
||||
->addOrderBy('date.new', 'DESC')
|
||||
->addOrderBy('date.concept_sorting')
|
||||
->addOrderBy('date.date_start')
|
||||
;
|
||||
|
||||
if ($filterSettings['productsPid']) {
|
||||
@@ -165,10 +172,10 @@ class ProductRepository extends AbstractRepository
|
||||
}
|
||||
|
||||
if (empty($filterSettings['productUid'])) {
|
||||
$qb->andWhere($qb->expr()->eq('product_searchable', 1));
|
||||
$qb->andWhere($qb->expr()->eq('date.product_searchable', 1));
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->eq(
|
||||
'product',
|
||||
'date.product',
|
||||
$qb->createNamedParameter($filterSettings['productUid'], Connection::PARAM_INT))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace EP\EpProducts\Middleware;
|
||||
|
||||
use EP\EpProducts\Service\HotelImageService;
|
||||
use EP\EpProducts\Service\MyEpImageService;
|
||||
use EP\EpProducts\Service\ProductImageService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -167,7 +167,7 @@ class ProductApiMiddleware implements MiddlewareInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
$images = GeneralUtility::makeInstance(HotelImageService::class)
|
||||
$images = GeneralUtility::makeInstance(MyEpImageService::class)
|
||||
->getImages((int) $hotel['uid']);
|
||||
|
||||
return [
|
||||
|
||||
@@ -111,6 +111,33 @@ class ApiClient implements LoggerAwareInterface
|
||||
return $this->requestJson('GET', 'pickups-planning/' . $travelCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function getPriceConfigData(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||
{
|
||||
return $this->requestJson('GET', 'contingents/calendar', [
|
||||
'query' => [
|
||||
'hotelCode' => $hotelCode,
|
||||
'dateFrom' => $dateFrom->format('Y-m-d'),
|
||||
'dateTo' => $dateTo->format('Y-m-d'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function getPriceTableData(string $hotelCode, string $year): array
|
||||
{
|
||||
return $this->requestJson('GET', 'contingents/prices', [
|
||||
'query' => [
|
||||
'hotelCode' => $hotelCode,
|
||||
'year' => $year,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
/***************************************************************
|
||||
*
|
||||
* Copyright notice
|
||||
*
|
||||
* (c) 2018 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!
|
||||
***************************************************************/
|
||||
|
||||
class MyEpImageService extends AbstractImageService
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'tx_epproducts_domain_model_hotel';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $field = 'images';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $imageSizes = [
|
||||
's' => [
|
||||
'width' => '480',
|
||||
'height' => '360c-100',
|
||||
],
|
||||
'm' => [
|
||||
'width' => '768',
|
||||
'height' => '576c-100',
|
||||
],
|
||||
'l' => [
|
||||
'width' => '1280',
|
||||
'height' => '960c-100',
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,10 @@ services:
|
||||
arguments:
|
||||
$cache: '@cache.ep_cache'
|
||||
|
||||
EP\EpProducts\Service\MyEpImageService:
|
||||
arguments:
|
||||
$cache: '@cache.ep_cache'
|
||||
|
||||
EP\EpProducts\Controller\AjaxCalendarController:
|
||||
arguments:
|
||||
$cache: '@cache.ep_cache'
|
||||
@@ -48,6 +48,8 @@ plugin.tx_epproducts {
|
||||
groupsPriceToEmail = [email protected]
|
||||
# cat=epproducts/130/120; type=string; label=Email recipient for groups price inquiries
|
||||
groupsPriceToName = EP Reisen
|
||||
# cat=epproducts/130/130; type=string; label=Email recipient for groups price inquiries
|
||||
groupsBookingUrl = https://myep.ddev.site/groups/booking
|
||||
# cat=epproducts/140/100; type=string; label=Room types for pricetables (CSV)
|
||||
priceTableRoomTypes = 1,2,3,4,5,6,7,8,9,10
|
||||
# cat=epproducts/140/110; type=string; label=Ignore following IPs in searchlog
|
||||
|
||||
@@ -54,6 +54,7 @@ plugin.tx_epproducts {
|
||||
groupsPriceToEmail = {$plugin.tx_epproducts.settings.groupsPriceToEmail}
|
||||
groupsPriceToName = {$plugin.tx_epproducts.settings.groupsPriceToName}
|
||||
groupInquiryFormConceptUids = {$plugin.tx_epproducts.settings.groupInquiryFormConceptUids}
|
||||
groupsBookingUrl = {$plugin.tx_epproducts.settings.groupsBookingUrl}
|
||||
logoImage = {$plugin.tx_eptheme.settings.logoImage}
|
||||
themekey = {$plugin.tx_eptheme.settings.themekey}
|
||||
globalConceptCode = {$plugin.tx_eptheme.settings.globalConceptCode}
|
||||
@@ -173,6 +174,9 @@ tx_epproducts_ajax_json {
|
||||
}
|
||||
AjaxCalendar {
|
||||
1 = range
|
||||
2 = priceConfig
|
||||
3 = priceTable
|
||||
4 = priceCalendar
|
||||
}
|
||||
AjaxContingent {
|
||||
1 = list
|
||||
|
||||
@@ -276,7 +276,7 @@ $boot = function () {
|
||||
'AjaxDate' => 'dates',
|
||||
'AjaxContingent' => 'list,rooms',
|
||||
'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms',
|
||||
'AjaxCalendar' => 'range,contingents,availableRooms',
|
||||
'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable',
|
||||
'AjaxWatchlist' => 'list',
|
||||
'AjaxGroupsPrice' => 'processForm',
|
||||
'AjaxTeaserPopup' => 'concept',
|
||||
@@ -287,7 +287,7 @@ $boot = function () {
|
||||
'AjaxDate' => 'dates',
|
||||
'AjaxContingent' => 'list,rooms',
|
||||
'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms',
|
||||
'AjaxCalendar' => 'range,contingents,availableRooms',
|
||||
'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable',
|
||||
'AjaxWatchlist' => 'list',
|
||||
'AjaxGroupsPrice' => 'processForm',
|
||||
'AjaxTeaserPopup' => 'concept',
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = ['mount', 'error', 'message', 'button', 'resetButton', 'day', 'month', 'prevButton', 'nextButton']
|
||||
|
||||
static values = {
|
||||
hotelCode: String,
|
||||
groupsBookingUrl: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.startDate = null
|
||||
this.endDate = null
|
||||
this.currentIndex = 0
|
||||
|
||||
// mountTarget carries hx-get, so HTMX fires its events there; listening here
|
||||
// scopes to this calendar instance and avoids catching events from other HTMX elements.
|
||||
this.mountTarget.addEventListener('htmx:afterSwap', () => this.showMonths(0))
|
||||
this.mountTarget.addEventListener('htmx:responseError', () => this.showError())
|
||||
}
|
||||
|
||||
showMonths(startIndex) {
|
||||
this.currentIndex = startIndex
|
||||
this.monthTargets.forEach((m, i) => {
|
||||
m.classList.toggle('hidden', i < startIndex || i >= startIndex + 2)
|
||||
})
|
||||
if (this.hasPrevButtonTarget) this.prevButtonTarget.disabled = startIndex === 0
|
||||
if (this.hasNextButtonTarget) this.nextButtonTarget.disabled = startIndex >= this.monthTargets.length - 2
|
||||
}
|
||||
|
||||
prevMonths() {
|
||||
this.showMonths(Math.max(0, this.currentIndex - 1))
|
||||
}
|
||||
|
||||
nextMonths() {
|
||||
this.showMonths(Math.min(this.currentIndex + 1, this.monthTargets.length - 2))
|
||||
}
|
||||
|
||||
selectDate(event) {
|
||||
const btn = event.currentTarget
|
||||
// cooltipz shows on :focus as well as :hover; blurring dismisses it after a click.
|
||||
btn.blur()
|
||||
const date = btn.dataset.date
|
||||
const status = btn.dataset.status
|
||||
|
||||
// All day buttons carry data-action for template simplicity; non-selectable ones are filtered here.
|
||||
if (!status || status === 'blocked') return
|
||||
|
||||
if (this.startDate && this.endDate) {
|
||||
this.resetSelection()
|
||||
}
|
||||
|
||||
if (!this.startDate) {
|
||||
// checkout-only days allow departure but not arrival
|
||||
if (status === 'checkout-only') {
|
||||
this.showMessage('Bitte wähle einen Anreisetag.')
|
||||
return
|
||||
}
|
||||
this.startDate = date
|
||||
this.endDate = null
|
||||
this.updateDisplay()
|
||||
this.showMessage('Bitte wähle das Abreisedatum.')
|
||||
this.disableButton()
|
||||
return
|
||||
}
|
||||
|
||||
if (date === this.startDate) {
|
||||
this.resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize so from < to regardless of which date the user clicked first.
|
||||
const [from, to] = date < this.startDate ? [date, this.startDate] : [this.startDate, date]
|
||||
|
||||
if (this.hasBlockedInRange(from, to)) {
|
||||
this.resetSelection()
|
||||
this.showMessage('Dieser Zeitraum enthält nicht verfügbare Daten.')
|
||||
return
|
||||
}
|
||||
|
||||
const startBtn = this.dayTargets.find(b => b.dataset.date === from)
|
||||
const minNights = parseInt(startBtn?.dataset.minNights) || 0
|
||||
const nights = (new Date(to) - new Date(from)) / 86400000 // ms → days
|
||||
|
||||
// Short-stay warning is advisory only; the booking link is still enabled below.
|
||||
if (minNights > 0 && nights < minNights) {
|
||||
this.showMessage(`Weniger als ${minNights} Nächte nur auf Anfrage.`)
|
||||
} else {
|
||||
this.hideMessage()
|
||||
}
|
||||
|
||||
this.startDate = from
|
||||
this.endDate = to
|
||||
this.updateDisplay()
|
||||
this.enableButton(from, to)
|
||||
this.dispatch('range-select', { detail: { dateFrom: from, dateTo: to } })
|
||||
}
|
||||
|
||||
hasBlockedInRange(from, to) {
|
||||
for (const btn of this.dayTargets) {
|
||||
const d = btn.dataset.date
|
||||
// End date is exclusive: guests may check out of a blocked day, so it is not a hard blocker.
|
||||
if (d >= from && d < to && btn.dataset.status === 'blocked') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
for (const btn of this.dayTargets) {
|
||||
const d = btn.dataset.date
|
||||
btn.classList.remove('pc-day--pending-start', 'pc-day--selected-start', 'pc-day--selected-end', 'pc-day--in-range')
|
||||
if (!this.startDate) continue
|
||||
if (d === this.startDate) {
|
||||
btn.classList.add(this.endDate ? 'pc-day--selected-start' : 'pc-day--pending-start')
|
||||
} else if (this.endDate) {
|
||||
if (d === this.endDate) btn.classList.add('pc-day--selected-end')
|
||||
else if (d > this.startDate && d < this.endDate) btn.classList.add('pc-day--in-range')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetSelection() {
|
||||
this.startDate = null
|
||||
this.endDate = null
|
||||
this.updateDisplay()
|
||||
this.hideMessage()
|
||||
this.disableButton()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetSelection()
|
||||
}
|
||||
|
||||
enableButton(dateFrom, dateTo) {
|
||||
if (!this.hasButtonTarget) return
|
||||
const params = new URLSearchParams({
|
||||
hotel_code: this.hotelCodeValue,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
})
|
||||
this.buttonTarget.href = `${this.groupsBookingUrlValue}?${params}`
|
||||
this.buttonTarget.textContent = `${this.formatDate(dateFrom)}–${this.formatDate(dateTo)} Anfragen / Buchen`
|
||||
this.buttonTarget.classList.remove('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
|
||||
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
disableButton() {
|
||||
if (!this.hasButtonTarget) return
|
||||
this.buttonTarget.removeAttribute('href')
|
||||
this.buttonTarget.textContent = 'Anfragen / Buchen'
|
||||
this.buttonTarget.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
|
||||
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
formatDate(iso) {
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}.${m}.${y.slice(2)}`
|
||||
}
|
||||
|
||||
showMessage(text) {
|
||||
if (this.hasMessageTarget) {
|
||||
this.messageTarget.textContent = text
|
||||
}
|
||||
}
|
||||
|
||||
hideMessage() {
|
||||
if (this.hasMessageTarget) {
|
||||
this.messageTarget.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
showError() {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -1,5 +1,4 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { useDispatch } from 'stimulus-use'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
@@ -9,10 +8,6 @@ export default class extends Controller {
|
||||
|
||||
static values = { activeTab: String }
|
||||
|
||||
initialize() {
|
||||
useDispatch(this)
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.activeTabValue = this.tabTargets[0].dataset.tab
|
||||
}
|
||||
@@ -20,7 +15,8 @@ export default class extends Controller {
|
||||
select(e) {
|
||||
let selectedTab = e.currentTarget.dataset.tab
|
||||
this.activeTabValue = selectedTab
|
||||
this.dispatch('selected', selectedTab)
|
||||
this.dispatch('selected', { detail: selectedTab })
|
||||
this.dispatch('tab-' + selectedTab)
|
||||
}
|
||||
|
||||
activeTabValueChanged() {
|
||||
|
||||
@@ -44,6 +44,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Price calendar (hand-rolled)
|
||||
.pc-loading-indicator {
|
||||
display: none;
|
||||
|
||||
&.htmx-request {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.pc-day {
|
||||
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer;
|
||||
|
||||
// Status colors — [data-status] attribute adds specificity (0,2,0)
|
||||
&[data-status="ok"] {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
&[data-status="blocked-to-ok"] {
|
||||
background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%);
|
||||
}
|
||||
&[data-status="checkout-only"] {
|
||||
background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%);
|
||||
}
|
||||
&[data-status="blocked"] {
|
||||
@apply bg-gray-400 cursor-default;
|
||||
}
|
||||
&[data-status=""], &:not([data-status]) {
|
||||
@apply text-gray-400 cursor-default;
|
||||
}
|
||||
|
||||
// Compound selector gives (0,2,0) specificity, matching [data-status] rules; source order wins.
|
||||
&.pc-day--pending-start,
|
||||
&.pc-day--selected-start,
|
||||
&.pc-day--selected-end {
|
||||
@apply bg-none outline-none bg-ep-secondary text-gray-700;
|
||||
}
|
||||
|
||||
&.pc-day--in-range {
|
||||
@apply bg-none bg-ep-secondary text-gray-700 opacity-55;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps
|
||||
@import "~leaflet/dist/leaflet.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<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 data-controller="price-calendar"
|
||||
data-price-calendar-hotel-code-value="{product.calendarHotel.code}"
|
||||
data-price-calendar-groups-booking-url-value="{settings.groupsBookingUrl}"
|
||||
class="relative p-4 bg-zinc-50">
|
||||
<div class="pc-loading-indicator items-center space-x-4 text-sm p-4"
|
||||
id="pc-loading-{product.calendarHotel.uid}">
|
||||
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
|
||||
<span>Preiskalender wird geladen …</span>
|
||||
</div>
|
||||
<div data-price-calendar-target="mount"
|
||||
hx-get="{ep:uri.ajax(action: 'priceCalendar', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel}', format: 'html')}"
|
||||
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}"
|
||||
hx-indicator="#pc-loading-{product.calendarHotel.uid}">
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="h-8 text-sm" data-price-calendar-target="message"></div>
|
||||
<div class="h-8 text-sm text-right">
|
||||
<button type="button"
|
||||
class="hidden underline"
|
||||
data-price-calendar-target="resetButton"
|
||||
data-action="click->price-calendar#reset">
|
||||
Auswahl zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a class="button bg-button w-full mt-2 opacity-50 cursor-not-allowed pointer-events-none"
|
||||
data-price-calendar-target="button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Anfragen / Buchen
|
||||
</a>
|
||||
<div class="hidden p-4 text-sm text-red-600" data-price-calendar-target="error">
|
||||
Der Kalender konnte aktuell nicht geladen werden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<html data-namespace-typo3-fluid="true" lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
|
||||
|
||||
<f:if condition="{error}">
|
||||
<f:else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button type="button" class="px-3 py-1 text-sm bg-zinc-100 hover:bg-zinc-200 rounded transition-colors disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none"
|
||||
data-price-calendar-target="prevButton"
|
||||
data-action="click->price-calendar#prevMonths"
|
||||
disabled>← Vorherige</button>
|
||||
<button type="button" class="px-3 py-1 text-sm bg-zinc-100 hover:bg-zinc-200 rounded transition-colors disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none"
|
||||
data-price-calendar-target="nextButton"
|
||||
data-action="click->price-calendar#nextMonths">Nächste →</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<f:for each="{calendarMonths}" as="month" iteration="i">
|
||||
<div data-month-index="{i.index}" data-price-calendar-target="month">
|
||||
<div class="font-semibold text-center py-2 text-zinc-700">
|
||||
<f:format.date format="%B %Y">{month.date}</f:format.date>
|
||||
</div>
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Mo</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Di</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Mi</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Do</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Fr</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">Sa</th>
|
||||
<th class="py-1 text-center text-xs text-zinc-500 font-normal">So</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<f:for each="{month.weeks}" as="week">
|
||||
<tr>
|
||||
<f:for each="{week}" as="day">
|
||||
<td class="p-px">
|
||||
<f:if condition="{day.inMonth}">
|
||||
<f:then>
|
||||
<f:variable name="dateKey" value="{f:format.date(format: 'Y-m-d', date: day.date)}"/>
|
||||
<f:variable name="dayData" value="{priceDataByDate.{dateKey}}"/>
|
||||
<button type="button"
|
||||
class="pc-day"
|
||||
data-price-calendar-target="day"
|
||||
data-action="click->price-calendar#selectDate"
|
||||
data-date="{dateKey}"
|
||||
data-status="{dayData.status}"
|
||||
data-min-nights="{dayData.minNights}"
|
||||
<f:if condition="{dayData.tooltip}">aria-label="{dayData.tooltip}" data-cooltipz-dir="top"</f:if>>
|
||||
{f:format.date(format: 'j', date: day.date)}
|
||||
</button>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<div class="pc-day"> </div>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</td>
|
||||
</f:for>
|
||||
</tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</f:for>
|
||||
</div>
|
||||
</f:else>
|
||||
</f:if>
|
||||
|
||||
</html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
{priceConfig -> f:format.json() ->f:format.raw()}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<html data-namespace-typo3-fluid="true" lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
|
||||
|
||||
<f:if condition="{priceTable.success} && {priceTable.data -> f:count()}">
|
||||
<div class="w-full overflow-x-scroll mb-4">
|
||||
<h3 class="font-bold mt-4 mb-2">
|
||||
Preise {year}
|
||||
</h3>
|
||||
<table class="w-full mb-4 bg-zinc-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
|
||||
von
|
||||
</th>
|
||||
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
|
||||
bis
|
||||
</th>
|
||||
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
|
||||
Saison
|
||||
</th>
|
||||
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
|
||||
Pax bis
|
||||
</th>
|
||||
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
|
||||
Mindestmiete pro Nacht
|
||||
</th>
|
||||
<th class="px-2 py-1 md:p-2 text-right text-sm md:text-base" scope="col">
|
||||
weitere Personen pro Nacht
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<f:for each="{priceTable.data}" as="row">
|
||||
<tr class="odd:bg-zinc-50 hover:bg-zinc-100 odd:hover:bg-zinc-100">
|
||||
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
|
||||
{row.dateFrom -> f:format.date(format: 'd.m.Y')}
|
||||
</td>
|
||||
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
|
||||
{row.dateTo -> f:format.date(format: 'd.m.Y')}
|
||||
</td>
|
||||
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-sm md:text-base">
|
||||
<f:switch expression="{row.season}">
|
||||
<f:case value="adv_secondary">VNS</f:case>
|
||||
<f:case value="secondary">NS</f:case>
|
||||
<f:case value="peak">HS</f:case>
|
||||
<f:defaultCase>{row.season}</f:defaultCase>
|
||||
</f:switch>
|
||||
</td>
|
||||
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-right text-sm md:text-base">
|
||||
{row.includedPax}
|
||||
</td>
|
||||
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-right text-sm md:text-base">
|
||||
<f:if condition="{row.type} == 'discount'">
|
||||
<f:then>
|
||||
<f:if condition="{row.defaultPricePerNight}">
|
||||
<span class="line-through text-zinc-400 mr-1">{row.defaultPricePerNight -> f:format.currency(currencySign: row.currency)}</span>
|
||||
</f:if>
|
||||
{row.pricePerNight -> f:format.currency(currencySign: row.currency)}
|
||||
</f:then>
|
||||
<f:else>
|
||||
{row.pricePerNight -> f:format.currency(currencySign: row.currency)}
|
||||
</f:else>
|
||||
</f:if>
|
||||
</td>
|
||||
<td class="px-2 py-1 md:p-2 text-right text-sm md:text-base">
|
||||
{row.priceAdditionalPerson -> f:format.currency(currencySign: row.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
</html>
|
||||
@@ -126,11 +126,29 @@
|
||||
<div class="mb-4{f:if(condition: settings.hotelOnTop, then: ' hidden')}"
|
||||
data-tabs-target="tab"
|
||||
data-tab="dates">
|
||||
|
||||
<f:if condition="{hotel.groupsPriceConfigs} && {nonBookableInfo}">
|
||||
<div class="alert alert-info mb-4" data-rte-content>
|
||||
{nonBookableInfo.text -> f:format.html()}
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{product.calendarHotel}">
|
||||
<f:render partial="Product/PriceCalendar.html" arguments="{_all}"/>
|
||||
|
||||
<f:variable name="currentYear" value="{f:format.date(date: 'now', format: 'Y')}"/>
|
||||
<div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: currentYear}', format: 'html')}"
|
||||
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}">
|
||||
<f:image src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt="Loading" title="Loading"/>
|
||||
</div>
|
||||
|
||||
<f:variable name="nextYear" value="{f:format.date(date: '+1 year', format: 'Y')}"/>
|
||||
<div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: nextYear}', format: 'html')}"
|
||||
hx-trigger="{f:if(condition: settings.hotelOnTop, then: 'tabs:tab-dates from:body once', else: 'load')}">
|
||||
<f:image src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt="Loading" title="Loading"/>
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{isProductWithExcludedConcept}">
|
||||
<f:if condition="{product.region.season} == 'w'">
|
||||
<div class="bg-ep-primary-light text-white p-4">
|
||||
@@ -146,6 +164,7 @@
|
||||
</div>
|
||||
</f:if>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{product.daytrip}">
|
||||
<f:then>
|
||||
<f:render section="DaytripDateSelect" arguments="{_all}"/>
|
||||
@@ -164,10 +183,12 @@
|
||||
<div class="hidden" data-product-details-target="priceTableContainer"></div>
|
||||
</f:else>
|
||||
</f:if>
|
||||
|
||||
<div class="lg:hidden">
|
||||
<f:render section="SidebarGallery" arguments="{_all}"/>
|
||||
<f:render section="SidebarButtons" arguments="{_all}"/>
|
||||
</div>
|
||||
|
||||
<f:render partial="Product/Details" arguments="{_all}"/>
|
||||
</div>
|
||||
</f:section>
|
||||
|
||||
Reference in new issue
Block a user