feat: groups bookings calendar

This commit is contained in:
Björn Fromme
2026-08-17 08:40:41 +02:00
committed by fromme
parent b19d09ef64
commit 7342db4afa
16 changed files with 724 additions and 93 deletions
@@ -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,151 @@ 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;
$errorMessage = '';
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;
$errorMessage = $e->getMessage();
}
} else {
$error = true;
$errorMessage = 'Missing hotel code';
}
$this->view->assignMultiple([
'priceDataByDate' => $priceDataByDate,
'calendarMonths' => $this->buildCalendarMonths($dateRange['period']),
'error' => $error,
'errorMessage' => $errorMessage,
]);
}
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 +218,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 +239,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>>
*/
@@ -217,27 +217,50 @@ class ApiMiddleware implements MiddlewareInterface
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
// map fields to icon ids and labels
$icons = [];
if (($hotel['beds_count'] ?? 0) > 0) {
$icons['bed'] = sprintf('%d Betten', $hotel['beds_count']);
}
if (($hotel['parking_count'] ?? 0) > 0) {
$icons['parking'] = sprintf('%d Parkplätze', $hotel['parking_count']);
}
if ($hotel['altitude'] ?? null) {
$icons['mountain'] = $hotel['altitude'];
}
if ($hotel['distance_bus'] ?? null) {
$icons['bus'] = $hotel['distance_bus'];
}
if ($hotel['distance_lift'] ?? null) {
$icons['lift'] = $hotel['distance_lift'];
}
if ($hotel['shower'] ?? null) {
$icons['shower'] = $hotel['shower'];
}
if ($hotel['partyroom'] ?? false) {
$icons['party'] = 'Partyzimmer';
}
if ($hotel['gamesroom'] ?? false) {
$icons['dice'] = 'Spielezimmer';
}
if ($hotel['sauna'] ?? false) {
$icons['sauna'] = 'Sauna';
}
if ($hotel['wifi'] ?? false) {
$icons['wifi'] = 'WiFi';
}
return [
'name' => $hotel['name'],
'code' => $hotel['code'],
'description' => $hotel['description'],
'features' => $hotel['features'],
'roomTypes' => $hotel['room_types'],
'additionalInformation' => $hotel['additional_information'],
'room_types' => $hotel['room_types'],
'additional_information' => $hotel['additional_information'],
'address' => $hotel['address'],
'images' => $images,
'icons' => [
'bedsCount' => (int) $hotel['beds_count'],
'parkingCount' => (int) $hotel['parking_count'],
'altitude' => $hotel['altitude'],
'shower' => $hotel['shower'],
'partyroom' => (bool) $hotel['partyroom'],
'gamesroom' => (bool) $hotel['gamesroom'],
'sauna' => (bool) $hotel['sauna'],
'wifi' => (bool) $hotel['wifi'],
'distanceBus' => $hotel['distance_bus'],
'distanceLift' => $hotel['distance_lift'],
],
'icons' => $icons,
'region' => $this->fetchRegionData((int) $hotel['region']),
];
}
@@ -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
*/
@@ -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/init
# 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',
@@ -0,0 +1,185 @@
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.showButton()
})
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()
}
showButton() {
if (this.hasButtonTarget) this.buttonTarget.classList.remove('hidden')
}
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')
}
}
}
@@ -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 justify-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="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="hidden 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>
@@ -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="{error}">
<f:then>
<div class="p-4 text-sm text-red-600">
Der Kalender konnte aktuell nicht geladen werden.
</div>
<div class="hidden" data-price-calendar-error-detail>{errorMessage}</div>
</f:then>
<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>&#8592; 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 &#8594;</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">&nbsp;</div>
</f:else>
</f:if>
</td>
</f:for>
</tr>
</f:for>
</tbody>
</table>
</div>
</f:for>
</div>
</f:else>
</f:if>
</html>
@@ -0,0 +1 @@
{priceConfig -> f:format.json() ->f:format.raw()}
@@ -0,0 +1,79 @@
<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>
VNS = Vorteils-Nebensaison, NS = Nebensaison, HS = Hauptsaison
</div>
</div>
</f:if>
</html>
@@ -37,40 +37,20 @@
<div id="main" class="w-full lg:w-2/3">
<div class="lg:pr-4 flex flex-col space-y-8">
<div class="grid grid-cols-3 md:grid-cols-6 gap-px uppercase text-white text-sm md:text-base">
<f:if condition="{settings.hotelOnTop}">
<f:then>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="hotel"
class="py-2 uppercase bg-button bg-button--dark">
Unterkunft
</button>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="dates"
class="col-span-2 py-2 uppercase bg-button bg-button--dark">
Termine/Preise
</button>
</f:then>
<f:else>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="dates"
class="col-span-2 py-2 uppercase bg-button bg-button--dark">
Termine/Preise
</button>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="hotel"
class="py-2 uppercase bg-button bg-button--dark">
Unterkunft
</button>
</f:else>
</f:if>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="dates"
class="col-span-2 py-2 uppercase bg-button bg-button--dark">
Termine/Preise
</button>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
data-tab="hotel"
class="py-2 uppercase bg-button bg-button--dark">
Unterkunft
</button>
<button type="button"
data-action="tabs#select"
data-tabs-target="button"
@@ -95,16 +75,8 @@
</button>
</f:if>
</div>
<f:if condition="{settings.hotelOnTop}">
<f:then>
<f:render section="Hotel" arguments="{_all}"/>
<f:render section="Dates" arguments="{_all}"/>
</f:then>
<f:else>
<f:render section="Dates" arguments="{_all}"/>
<f:render section="Hotel" arguments="{_all}"/>
</f:else>
</f:if>
<f:render section="Dates" arguments="{_all}"/>
<f:render section="Hotel" arguments="{_all}"/>
<f:render section="City" arguments="{_all}"/>
<f:render section="Region" arguments="{_all}"/>
<f:render section="Journey" arguments="{_all}"/>
@@ -123,14 +95,32 @@
</f:section>
<f:section name="Dates">
<div class="mb-4{f:if(condition: settings.hotelOnTop, then: ' hidden')}"
<div class="mb-4"
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="load">
<f:render section="TableLoadingIndicator"/>
</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="load">
<f:render section="TableLoadingIndicator"/>
</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 +136,7 @@
</div>
</f:if>
</f:if>
<f:if condition="{product.daytrip}">
<f:then>
<f:render section="DaytripDateSelect" arguments="{_all}"/>
@@ -164,17 +155,19 @@
<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>
<f:section name="Hotel">
<div class="mb-4{f:if(condition: settings.hotelOnTop, else: ' hidden')}"
<div class="mb-4 hidden"
data-tabs-target="tab"
data-tab="hotel">
<f:render partial="Hotel/Details" arguments="{hotel: hotel, bars: 1, showNonBookableHint: 0, currentPageUid: currentPageUid, showInquiryForm: isProductWithGroupInquiryForm}"/>
@@ -209,7 +202,6 @@
<f:section name="Sidebar">
<f:render section="SidebarGallery" arguments="{_all}"/>
<f:render section="Calendar" arguments="{_all}"/>
<f:if condition="{product.tourLink}">
<div class="bg-zinc-50 p-4 mb-4"
data-controller="lightbox">
@@ -468,4 +460,10 @@
</f:if>
</f:section>
<f:section name="TableLoadingIndicator">
<div class="flex items-center justify-center space-x-4 text-sm p-4">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
<span>Preistabelle wird geladen …</span>
</div>
</f:section>
</html>