wip: groups bookings calendar

This commit is contained in:
Björn Fromme committed 2026-07-14 16:32:15 +02:00
1 parent 9c5a6747bc
commit 25789612bf
18 files changed
+701 -44

No files matched your search

+1 -1
View File
@@ -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
###
-16
View File
@@ -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>>
*/
@@ -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
*/
@@ -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',
]
];
}
@@ -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',
@@ -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')
}
}
}
@@ -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>
@@ -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>&#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,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>