From 7342db4afa71aae0d7e730a66e4ea626011bc8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 30 Jun 2026 17:53:17 +0200 Subject: [PATCH] feat: groups bookings calendar --- api.http | 2 +- composer.lock | 12 +- .../Controller/AjaxCalendarController.php | 191 ++++++++++++++++-- .../Classes/Middleware/ApiMiddleware.php | 51 +++-- .../ep_products/Classes/MyEP/ApiClient.php | 27 +++ .../TypoScript/constants.typoscript | 2 + .../Configuration/TypoScript/setup.typoscript | 4 + .../ext/ep_products/ext_localconf.php | 4 +- .../controllers/price_calendar_controller.js | 185 +++++++++++++++++ .../Assets/js/controllers/tabs_controller.js | 8 +- .../Private/Assets/scss/_vendor.scss | 41 ++++ .../Partials/Product/PriceCalendar.html | 42 ++++ .../Templates/AjaxCalendar/PriceCalendar.html | 76 +++++++ .../Templates/AjaxCalendar/PriceConfig.html | 1 + .../Templates/AjaxCalendar/PriceTable.html | 79 ++++++++ .../Private/Templates/Product/Detail.html | 92 +++++---- 16 files changed, 724 insertions(+), 93 deletions(-) create mode 100644 public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/price_calendar_controller.js create mode 100644 public/typo3conf/ext/ep_theme/Resources/Private/Partials/Product/PriceCalendar.html create mode 100644 public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html create mode 100644 public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceConfig.html create mode 100644 public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html diff --git a/api.http b/api.http index 624ef1d5..6b225a1d 100644 --- a/api.http +++ b/api.http @@ -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 ### diff --git a/composer.lock b/composer.lock index fd8e0adf..bfe9f287 100644 --- a/composer.lock +++ b/composer.lock @@ -4647,16 +4647,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -4705,7 +4705,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -4725,7 +4725,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-icu", diff --git a/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php b/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php index fb60965b..6d824308 100644 --- a/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php +++ b/public/typo3conf/ext/ep_products/Classes/Controller/AjaxCalendarController.php @@ -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> */ diff --git a/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php b/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php index 1d5bf5cd..967c909e 100644 --- a/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php +++ b/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php @@ -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']), ]; } diff --git a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php index 6a10d1ed..3c3152de 100644 --- a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php +++ b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php @@ -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 */ diff --git a/public/typo3conf/ext/ep_products/Configuration/TypoScript/constants.typoscript b/public/typo3conf/ext/ep_products/Configuration/TypoScript/constants.typoscript index f263f449..6d6c778d 100644 --- a/public/typo3conf/ext/ep_products/Configuration/TypoScript/constants.typoscript +++ b/public/typo3conf/ext/ep_products/Configuration/TypoScript/constants.typoscript @@ -48,6 +48,8 @@ plugin.tx_epproducts { groupsPriceToEmail = gruppen@ep-reisen.de # 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 diff --git a/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript b/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript index 0c65137a..a7002221 100644 --- a/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript +++ b/public/typo3conf/ext/ep_products/Configuration/TypoScript/setup.typoscript @@ -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 diff --git a/public/typo3conf/ext/ep_products/ext_localconf.php b/public/typo3conf/ext/ep_products/ext_localconf.php index 0687171e..d38b95f1 100644 --- a/public/typo3conf/ext/ep_products/ext_localconf.php +++ b/public/typo3conf/ext/ep_products/ext_localconf.php @@ -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', diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/price_calendar_controller.js b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/price_calendar_controller.js new file mode 100644 index 00000000..a0ee14c8 --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/price_calendar_controller.js @@ -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') + } + } +} diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/tabs_controller.js b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/tabs_controller.js index b5ca2849..0c620f37 100644 --- a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/tabs_controller.js +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/controllers/tabs_controller.js @@ -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() { diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/_vendor.scss b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/_vendor.scss index 7ad98519..7cab209a 100644 --- a/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/_vendor.scss +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/_vendor.scss @@ -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"; diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Partials/Product/PriceCalendar.html b/public/typo3conf/ext/ep_theme/Resources/Private/Partials/Product/PriceCalendar.html new file mode 100644 index 00000000..d9add912 --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Partials/Product/PriceCalendar.html @@ -0,0 +1,42 @@ + + +
+
+ + Preiskalender wird geladen … +
+
+
+
+
+
+ +
+
+ + +
+ + diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html new file mode 100644 index 00000000..e6d1d4aa --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceCalendar.html @@ -0,0 +1,76 @@ + + + + +
+ Der Kalender konnte aktuell nicht geladen werden. +
+ +
+ +
+ + +
+
+ +
+
+ {month.date} +
+ + + + + + + + + + + + + + + + + + + + + +
MoDiMiDoFrSaSo
+ + + + + + + +
 
+
+
+
+
+
+
+
+
+ + diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceConfig.html b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceConfig.html new file mode 100644 index 00000000..5bd064e4 --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceConfig.html @@ -0,0 +1 @@ +{priceConfig -> f:format.json() ->f:format.raw()} diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html new file mode 100644 index 00000000..2d480456 --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/AjaxCalendar/PriceTable.html @@ -0,0 +1,79 @@ + + + +
+

+ Preise {year} +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ von + + bis + + Saison + + Pax bis + + Mindestmiete pro Nacht + + weitere Personen pro Nacht +
+ {row.dateFrom -> f:format.date(format: 'd.m.Y')} + + {row.dateTo -> f:format.date(format: 'd.m.Y')} + + + VNS + NS + HS + {row.season} + + + {row.includedPax} + + + + + {row.defaultPricePerNight -> f:format.currency(currencySign: row.currency)} + + {row.pricePerNight -> f:format.currency(currencySign: row.currency)} + + + {row.pricePerNight -> f:format.currency(currencySign: row.currency)} + + + + {row.priceAdditionalPerson -> f:format.currency(currencySign: row.currency)} +
+
+ VNS = Vorteils-Nebensaison, NS = Nebensaison, HS = Hauptsaison +
+
+
+ + diff --git a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html index 88c27adb..671d7980 100644 --- a/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html +++ b/public/typo3conf/ext/ep_theme/Resources/Private/Templates/Product/Detail.html @@ -37,40 +37,20 @@
- - - - - - - - - - + +
- - - - - - - - - - + + @@ -123,14 +95,32 @@ -
+
{nonBookableInfo.text -> f:format.html()}
+ + + + + +
+ +
+ + +
+ +
+
+
@@ -146,6 +136,7 @@
+ @@ -164,17 +155,19 @@ +
+
-
@@ -209,7 +202,6 @@ -
@@ -468,4 +460,10 @@ + +
+ + Preistabelle wird geladen … +
+