feat: groups bookings calendar
This commit is contained in:
@@ -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',
|
||||
|
||||
+185
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 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>
|
||||
+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="{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>← 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()}
|
||||
+79
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user