Compare commits

...
19 Commits
Author SHA1 Message Date
fromme 93ed5c6962 fix: harden encoded filter settings against malformed and stale input 2026-09-22 14:49:04 +02:00
fromme 942724fdd1 feat: derive room type from occupancy instead of a mapping table 2026-09-22 14:47:04 +02:00
frommeandClaude Opus 5 e52f7dcff8 feat: post application forms to MyEP with label-keyed extra values
Forms built in the backend form editor use generated element identifiers
(text-1, multicheckbox-2, ...) that carry no meaning and cannot be renamed
without breaking the other finishers configured on the form. ApplicationApiFinisher
therefore sends two halves: a small mapped core (firstName, lastName, email, ...)
that a developer configures per form and MyEP can rely on, plus every remaining
submitted value keyed by the element label for a human to read.

Each submission also carries where it came from: the form identifier and its
editor-given name, plus the uid and canonical URL of the page it was submitted
from - the same definition is embedded on several pages, so the page is what
distinguishes them. The URL is generated through the site router in the visitor's
language, like AddReferrerFinisher does it.

Labels come from the form definition rather than TranslationService, so the keys
do not shift with the site language. Values are normalized for JSON: dates to
Y-m-d, uploads to the file name, multi-value elements to a list, empty values
dropped, unsupported objects skipped with a log warning.

Like BpnApiFinisher the request failure is only logged, so a MyEP outage never
costs the application mail.

The endpoint (POST applications) does not exist in MyEP yet; the contract handed
over to that team lives in .ddev/plans/MYEP-APPLICATION-ENDPOINT.md. Until it is
built the finisher is registered but attached to no form.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 13:42:07 +02:00
fromme 30e531497d fix: default Geoapify base url to Europe 2026-09-22 10:54:41 +02:00
fromme 2cb37e760a fix: serve svg sprite locally for same-origin restrictions 2026-09-22 10:35:58 +02:00
fromme 3ff3251419 feat: integrate with Geaoapify for maps tiles 2026-09-22 10:25:20 +02:00
fromme abcd39107b fix: serve css/js via cdn 2026-09-22 09:32:15 +02:00
fromme 599e3c7ee2 fix: accept 409 from the MyEP contact form endpoint 2026-09-22 08:29:58 +02:00
fromme 6ff308015a chore: optimize deployer config 2026-09-15 11:38:56 +02:00
fromme 6e0479bf22 fix: reduce timeout values for snowreport import 2026-09-15 11:28:14 +02:00
fromme fb8fddf669 fix: make snowreport import type-safe and drop wget shell-out 2026-09-15 11:14:33 +02:00
fromme f745c7b683 chore: exclude temp dir from repository 2026-09-15 11:08:49 +02:00
fromme 9b3550ec40 feat: migrate to native lazy loading of images 2026-09-12 12:08:09 +02:00
fromme d9943619f6 chore: cleanup 2026-09-12 12:04:52 +02:00
fromme e33b57c9f1 feat: disable chatbot in page footer 2026-09-12 12:04:33 +02:00
fromme dc594efee1 chore: remove obsolete vue component 2026-09-12 11:52:46 +02:00
fromme 432d8da350 chore: update redis config 2026-09-12 11:31:29 +02:00
fromme 5b338eeab6 chore: align redis config with prod 2026-09-12 11:27:08 +02:00
fromme 05ceae5100 fix: simplify generated captchas to improve ux 2026-09-12 11:22:49 +02:00
78 changed files with 1202 additions and 4853 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
# http://download.redis.io/redis-stable/redis.conf # http://download.redis.io/redis-stable/redis.conf
# http://download.redis.io/redis-stable/sentinel.conf # http://download.redis.io/redis-stable/sentinel.conf
maxmemory 2048mb maxmemory 128mb
maxmemory-policy allkeys-lfu maxmemory-policy allkeys-lru
# to disable Redis persistence, remove ddev-generated from this file, # to disable Redis persistence, remove ddev-generated from this file,
# and uncomment the two lines below: # and uncomment the two lines below:
-3284
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
16 18
+1
View File
@@ -15,6 +15,7 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"b13/container": "^1.3", "b13/container": "^1.3",
"blueways/bw-captcha": "^3.1", "blueways/bw-captcha": "^3.1",
Generated
+3 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "e3a433b77a2142f116e57b54c4172ffa", "content-hash": "38fb94ebf504e8413d37c94f929e4b47",
"packages": [ "packages": [
{ {
"name": "b13/container", "name": "b13/container",
@@ -9647,11 +9647,12 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*" "ext-simplexml": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"platform-overrides": { "platform-overrides": {
"php": "7.4.13" "php": "7.4.33"
}, },
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }
+10 -12
View File
@@ -63,41 +63,39 @@ host('prod')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/usr/home/eptypo3/public_html/{{application}}') ->setDeployPath('/usr/home/eptypo3/public_html/{{application}}')
->set('buspronet_path', '/usr/home/eptypo3/public_html/kuschick/buspronet/buspronet')
->set('bin/php', '/usr/bin/php74') ->set('bin/php', '/usr/bin/php74')
->set('http_user', 'eptypo3') ->set('http_user', 'eptypo3')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('bin/composer', '/usr/bin/composer')
->set('composer_options', '--prefer-dist --no-progress --no-interaction --optimize-autoloader')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://www.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/{{typo3_webroot}}/ --web-url=https://www.ep-reisen.de')
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
// Build locally first, so a failing build aborts before the server is touched at all.
'deploy:assets:build',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets:build',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
// The schema has to exist before the new code starts serving traffic.
'typo3:database:migrate', 'typo3:database:migrate',
'typo3:language:update', 'typo3:language:update',
// deploy:publish == deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success
'deploy:publish', 'deploy:publish',
'cachetool:clear:opcache',
'typo3:cache:flush',
'deploy:buspronet:symlink',
]); ]);
task('deploy:buspronet:symlink', function () { // Caches belong to the release that goes live, so they are cleared after the symlink flip. Hooking
run('ln -s {{buspronet_path}} {{release_or_current_path}}/public/buchung'); // into deploy:symlink rather than appending to the list above keeps them inside deploy:publish, ahead
}); // of deploy:unlock and deploy:success - a failing cache task must not follow a declared success.
after('deploy:symlink', 'cachetool:clear:opcache');
after('deploy:symlink', 'typo3:cache:flush');
task ('deploy:assets:build', function () { task ('deploy:assets:build', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm rebuild node-sass');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
}); });
-18
View File
@@ -21,10 +21,8 @@
"glightbox": "^3.0.9", "glightbox": "^3.0.9",
"htmx.org": "^1.9.12", "htmx.org": "^1.9.12",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lazysizes": "^5.3.2",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"picturefill": "^3.0.3",
"slick-carousel": "^1.8.1", "slick-carousel": "^1.8.1",
"sticky-js": "^1.3.0", "sticky-js": "^1.3.0",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
@@ -6017,12 +6015,6 @@
"shell-quote": "^1.8.4" "shell-quote": "^1.8.4"
} }
}, },
"node_modules/lazysizes": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/lazysizes/-/lazysizes-5.3.2.tgz",
"integrity": "sha512-22UzWP+Vedi/sMeOr8O7FWimRVtiNJV2HCa+V8+peZOw6QbswN9k58VUhd7i6iK5bw5QkYrF01LJbeJe0PV8jg==",
"license": "MIT"
},
"node_modules/leaflet": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -6963,16 +6955,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/picturefill": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/picturefill/-/picturefill-3.0.3.tgz",
"integrity": "sha512-JDdx+3i4fs2pkqwWZJgGEM2vFWsq+01YsQFT9CKPGuv2Q0xSdrQZoxi9XwyNARTgxiOdgoAwWQRluLRe/JQX2g==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/pify": { "node_modules/pify": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
-2
View File
@@ -24,10 +24,8 @@
"glightbox": "^3.0.9", "glightbox": "^3.0.9",
"htmx.org": "^1.9.12", "htmx.org": "^1.9.12",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lazysizes": "^5.3.2",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"picturefill": "^3.0.3",
"slick-carousel": "^1.8.1", "slick-carousel": "^1.8.1",
"sticky-js": "^1.3.0", "sticky-js": "^1.3.0",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
+31 -6
View File
@@ -47,24 +47,49 @@ if (getenv('IS_DDEV_PROJECT') == 'true') {
); );
} }
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cache_pages'] = [ // Fail fast instead of blocking indefinitely when redis is unreachable.
$redisOptions['connectionTimeout'] = 2;
// Note: the cache identifiers must stay unprefixed ('pages', not 'cache_pages'). These
// assignments replace the definitions from LocalConfiguration.php wholesale, so 'groups'
// and the compression/lifetime options have to be repeated here.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class, 'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + ['database' => 0], 'options' => $redisOptions + [
'database' => 0,
'compression' => true,
],
'groups' => ['pages'],
]; ];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cache_hash'] = [ $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['hash'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class, 'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + ['database' => 1], 'options' => $redisOptions + [
'database' => 1,
],
'groups' => ['pages'],
]; ];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cache_rootline'] = [ // Own database: RedisBackend::flush() issues FLUSHDB and the keys carry no per-cache
// prefix, so two caches sharing a database flush each other.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class, 'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class, 'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + ['database' => 1], 'options' => $redisOptions + [
'database' => 2,
'compression' => true,
'defaultLifetime' => 2592000,
],
'groups' => ['pages'],
]; ];
// Assets are served through the bunny.net CDN, which gzip/brotli-compresses at the edge.
// Pre-gzipped .gzip twins are served with Content-Encoding: gzip and cannot be processed
// by the CDN, so stop generating them. mod_deflate still compresses direct origin hits.
$GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 0;
// suppress php deprecation warnings flooding typo3 logs // suppress php deprecation warnings flooding typo3 logs
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] = $GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] =
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] & ~E_DEPRECATED & ~E_USER_DEPRECATED; $GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] & ~E_DEPRECATED & ~E_USER_DEPRECATED;
@@ -4,9 +4,6 @@ import Vue from 'vue'
import $ from 'jquery' import $ from 'jquery'
import VueSession from 'vue-session' import VueSession from 'vue-session'
import 'lazysizes';
import 'lazysizes/plugins/attrchange/ls.attrchange';
const slick = require('slick-carousel'); const slick = require('slick-carousel');
const Sticky = require('sticky-js'); const Sticky = require('sticky-js');
const $window = $(window); const $window = $(window);
@@ -38,9 +38,9 @@
<div class="card card--no-form offerlist-item__card"> <div class="card card--no-form offerlist-item__card">
<div class="card__inner"> <div class="card__inner">
<picture> <picture>
<source media="(max-width: 512px)" :data-srcset="offer.teaserImageMobile + ' 500w'"/> <source media="(max-width: 512px)" :srcset="offer.teaserImageMobile"/>
<img class="card__image scale lazyload" data-sizes="auto" <img class="card__image scale"
:data-src="offer.teaserImage" alt=""> :src="offer.teaserImage" alt="" loading="lazy">
</picture> </picture>
<div class="card__caption"> <div class="card__caption">
<span class="card__subtitle" v-html="offer.name"></span> <span class="card__subtitle" v-html="offer.name"></span>
@@ -87,12 +87,16 @@ class SearchController extends ActionController
public function initializeSearchresultAction() public function initializeSearchresultAction()
{ {
// Process encoded filtersettings from static search result // Process encoded filtersettings from static search result.
// Explicit filter settings win: they carry the visitor's current selection,
// whereas a stale 'f' riding along on a link would silently replace it.
$encodedFilterSettings = GeneralUtility::_GET('f'); $encodedFilterSettings = GeneralUtility::_GET('f');
if ($encodedFilterSettings) { if ($encodedFilterSettings && !$this->request->hasArgument('filterSettings')) {
$filterSettingsEncoder = new FilterSettingsEncoder(); $filterSettingsEncoder = new FilterSettingsEncoder();
$filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings); $filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings);
$this->request->setArgument('filterSettings', $filterSettings); if (count($filterSettings) > 0) {
$this->request->setArgument('filterSettings', $filterSettings);
}
} }
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true); $forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true);
$this->processFilterSettingsArgument($forcedConceptUids); $this->processFilterSettingsArgument($forcedConceptUids);
@@ -30,12 +30,17 @@ namespace EP\EpProducts\Domain\Model;
class Room class Room
{ {
const ROOM_TYPE_UNCLASSIFIED = 0;
const ROOM_TYPE_FOR_ONE = 1; const ROOM_TYPE_FOR_ONE = 1;
const ROOM_TYPE_FOR_TWO = 2; const ROOM_TYPE_FOR_TWO = 2;
const ROOM_TYPE_FOR_THREE = 3; const ROOM_TYPE_FOR_THREE = 3;
const ROOM_TYPE_FOR_FOUR = 4; const ROOM_TYPE_FOR_FOUR = 4;
const ROOM_TYPE_WITH_OTHERS = 5; const ROOM_TYPE_FOR_FIVE = 5;
const ROOM_TYPE_OTHER = 6; const ROOM_TYPE_FOR_SIX = 6;
const ROOM_TYPE_FOR_SEVEN = 7;
const ROOM_TYPE_FOR_EIGHT = 8;
const ROOM_TYPE_FOR_NINE_PLUS = 9;
const ROOM_TYPE_WITH_OTHERS = 10;
const ROOM_STATUS_AVAILABLE = 1; const ROOM_STATUS_AVAILABLE = 1;
const ROOM_STATUS_ON_REQUEST = 2; const ROOM_STATUS_ON_REQUEST = 2;
@@ -1,75 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Model;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 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 RoomMapping extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
/**
* @var string
*/
protected $code = '';
/**
* @var int
*/
protected $type = 0;
/**
* @return string
*/
public function getCode()
{
return $this->code;
}
/**
* @param string $code
*/
public function setCode($code)
{
$this->code = $code;
}
/**
* @return int
*/
public function getType()
{
return $this->type;
}
/**
* @param int $type
*/
public function setType($type)
{
$this->type = $type;
}
}
@@ -76,9 +76,11 @@ class ProductRepository extends AbstractRepository
'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded', 'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded',
'date.bus_available as busAvailable', 'date.new as new', 'date.bus_available as busAvailable', 'date.new as new',
'date.min_price as minPrice', 'date.nights as nights', 'date.available as available', 'date.min_price as minPrice', 'date.nights as nights', 'date.available as available',
'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton', 'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton'
'room.type as roomType'
) )
// The GROUP BY below collapses many room rows per group; an arbitrary
// room.type would hide most types from the filter facet.
->addSelectLiteral('GROUP_CONCAT(DISTINCT room.type) as roomTypes')
->from('tx_epproducts_domain_model_date', 'date') ->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid') ->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid') ->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
@@ -1,31 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Repository;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 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 RoomMappingRepository extends AbstractRepository
{}
@@ -100,7 +100,23 @@ class ApiClient implements LoggerAwareInterface
'json' => $addressData, 'json' => $addressData,
]); ]);
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT], 'POST', 'contactform'); // A conflict means the address is already registered, which is the normal answer to a
// resubmitted contact form and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform');
}
/**
* @throws ApiException
*/
public function submitApplication(array $applicationData): void
{
$response = $this->request('POST', 'applications', [
'json' => $applicationData,
]);
// A conflict means the submission is already known, which is the normal answer to a
// double-clicked submit button and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'applications');
} }
/** /**
@@ -60,11 +60,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $pid = 0; protected $pid = 0;
/**
* @var array
*/
protected $roomMappings = [];
/** /**
* @var array * @var array
*/ */
@@ -90,6 +85,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $importSourceFactory; protected $importSourceFactory;
/**
* @var RoomTypeResolver
*/
protected $roomTypeResolver;
/** /**
* @var array * @var array
*/ */
@@ -101,15 +101,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
* @param ConfigurationManagerInterface $configurationManager * @param ConfigurationManagerInterface $configurationManager
* @param CacheService $cacheService * @param CacheService $cacheService
* @param ProductImportSourceFactory $importSourceFactory * @param ProductImportSourceFactory $importSourceFactory
* @param RoomTypeResolver $roomTypeResolver
*/ */
public function __construct( public function __construct(
ConfigurationManagerInterface $configurationManager, ConfigurationManagerInterface $configurationManager,
CacheService $cacheService, CacheService $cacheService,
ProductImportSourceFactory $importSourceFactory ProductImportSourceFactory $importSourceFactory,
RoomTypeResolver $roomTypeResolver
) { ) {
$this->configurationManager = $configurationManager; $this->configurationManager = $configurationManager;
$this->cacheService = $cacheService; $this->cacheService = $cacheService;
$this->importSourceFactory = $importSourceFactory; $this->importSourceFactory = $importSourceFactory;
$this->roomTypeResolver = $roomTypeResolver;
$settings = GeneralUtility::removeDotsFromTS( $settings = GeneralUtility::removeDotsFromTS(
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT) $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
@@ -130,7 +133,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
public function import($path): int public function import($path): int
{ {
$this->db = $this->getDbConnection(); $this->db = $this->getDbConnection();
$this->getRoomMappings();
$this->getHotelMappings(); $this->getHotelMappings();
$this->importPickups($path); $this->importPickups($path);
$this->createTempTables(); $this->createTempTables();
@@ -594,13 +596,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue; continue;
} }
$roomTypeKey = mb_strtolower($code); $label = (string) $roomXml->attributes()['zimmertext'];
if (array_key_exists($roomTypeKey, $this->roomMappings)) { $roomMinPax = (int) $roomXml->attributes()['MinPax'];
$roomType = $this->roomMappings[$roomTypeKey]; $pax = (int) $roomXml->attributes()['MaxPax'];
} else {
$this->logger->warning('room type not found', ['code' => $code]);
continue;
}
$available = (int) $roomXml->attributes()['verfuegbar']; $available = (int) $roomXml->attributes()['verfuegbar'];
$price = (int) $roomXml->attributes()['preis']; $price = (int) $roomXml->attributes()['preis'];
@@ -610,12 +608,23 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue; continue;
} }
$pax = (int) $roomXml->attributes()['MinPax']; $roomType = $this->roomTypeResolver->resolve($code, $label, $pax, $roomMinPax);
if (0 === $minPax || $pax < $minPax) {
$minPax = $pax; if ($pax < 1 && $roomMinPax < 1) {
$this->logger->warning('room without occupancy', ['code' => $code, 'zimmertext' => $label]);
}
if ($this->roomTypeResolver->needsReview($code, $label, $pax)) {
$this->logger->warning(
'single-occupancy room without single-use marker',
['code' => $code, 'zimmertext' => $label]
);
}
if (0 === $minPax || $roomMinPax < $minPax) {
$minPax = $roomMinPax;
} }
$pax = (int) $roomXml->attributes()['MaxPax'];
$availableTotal += $available * $pax; $availableTotal += $available * $pax;
// Determine lowest price of available rooms // Determine lowest price of available rooms
@@ -648,7 +657,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'], 'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'],
'code' => $code, 'code' => $code,
'type' => $roomType, 'type' => $roomType,
'name' => (string) $roomXml->attributes()['zimmertext'], 'name' => $label,
'pax' => $pax, 'pax' => $pax,
'nights' => (int) $roomXml->attributes()['naechte'], 'nights' => (int) $roomXml->attributes()['naechte'],
'price' => $price, 'price' => $price,
@@ -791,16 +800,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
} }
protected function getRoomMappings(): void
{
$sql = 'SELECT code, type FROM tx_epproducts_domain_model_roommapping WHERE deleted = 0 AND hidden = 0';
$roomMappings = $this->db->fetchAllAssociative($sql);
foreach ($roomMappings as $mapping) {
$key = trim(mb_strtolower($mapping['code']));
$this->roomMappings[$key] = $mapping['type'];
}
}
protected function getHotelMappings(): void protected function getHotelMappings(): void
{ {
$sql = ' $sql = '
@@ -3,6 +3,7 @@
namespace EP\EpProducts\Service; namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Model\Dto\FilterOptions; use EP\EpProducts\Domain\Model\Dto\FilterOptions;
use EP\EpProducts\Domain\Model\Room;
use EP\EpProducts\Domain\Model\SearchResult; use EP\EpProducts\Domain\Model\SearchResult;
use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -212,6 +213,12 @@ class FilterService implements SingletonInterface
} }
} }
foreach ($item['rooms'] as $roomType) { foreach ($item['rooms'] as $roomType) {
// Type 0 is not a room (Baby) and must never become a filter option:
// it has no label, so it would render as an empty checkbox and leave
// a hole in the option list.
if ((int) $roomType === Room::ROOM_TYPE_UNCLASSIFIED) {
continue;
}
if (!\in_array($roomType, $roomTypes, false)) { if (!\in_array($roomType, $roomTypes, false)) {
$roomTypes[] = $roomType; $roomTypes[] = $roomType;
} }
@@ -76,11 +76,28 @@ class FilterSettingsEncoder
return implode('|', $encoded); return implode('|', $encoded);
} }
/**
* Decodes an encoded filter settings string.
*
* The format is strictly positional, so a string whose field count does not
* match the mapping cannot be interpreted: every field after a missing one
* would be read into the wrong setting. Such input is rejected outright
* rather than silently producing a shifted - and therefore wrong - result.
*
* @param string $data
* @return array the decoded settings, or an empty array if $data is malformed
*/
public function decode(string $data): array public function decode(string $data): array
{ {
$decoded = []; $mapping = $this->getMapping();
$values = explode('|', $data); $values = explode('|', $data);
foreach ($this->getMapping() as $index => $property)
if (count($values) !== count($mapping)) {
return [];
}
$decoded = [];
foreach ($mapping as $index => $property)
{ {
[$name, $type] = $property; [$name, $type] = $property;
$value = $values[$index]; $value = $values[$index];
@@ -0,0 +1,129 @@
<?php
namespace EP\EpProducts\Service;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 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!
***************************************************************/
use EP\EpProducts\Domain\Model\Room;
/**
* Derives the room type from the data each <preis> element carries.
*
* Replaces the former tx_epproducts_domain_model_roommapping lookup: zimmercode is
* neither unique per room nor a function of room type, so a code-keyed table cannot
* classify this data. MaxPax is the advertised occupancy and is present on every row.
*/
class RoomTypeResolver
{
/**
* Phrases in zimmertext that mark a bed shared with other customers.
*
* @var array
*/
const LABEL_PATTERNS_WITH_OTHERS = [
'bett im',
'mit ander',
];
/**
* Phrases in zimmertext that mark a deliberate single use of a larger room.
*
* @var array
*/
const LABEL_PATTERNS_SINGLE_USE = [
'einzel',
'1 person',
];
/**
* @param string $code zimmercode
* @param string $label zimmertext
* @param int $maxPax MaxPax
* @param int $minPax MinPax
* @return int one of the Room::ROOM_TYPE_* constants
*/
public function resolve(string $code, string $label, int $maxPax, int $minPax): int
{
// Not a room: "Babypreis für 0-2 Jährige". Kept in the date record for its
// price and availability, but hidden from the filter.
if ($code === DateImportService::CODE_BABYROOM) {
return Room::ROOM_TYPE_UNCLASSIFIED;
}
if ($this->matchesAny($label, static::LABEL_PATTERNS_WITH_OTHERS)) {
return Room::ROOM_TYPE_WITH_OTHERS;
}
$pax = $maxPax > 0 ? $maxPax : $minPax;
if ($pax >= 1) {
// Type numbering is chosen so that type === pax for pax 1-8.
return $pax >= 9 ? Room::ROOM_TYPE_FOR_NINE_PLUS : $pax;
}
// No usable occupancy - should never happen on current data, the caller logs it.
return Room::ROOM_TYPE_WITH_OTHERS;
}
/**
* Tripwire for an under-matching label pattern: a row classified as a single room
* whose label carries no single-use marker is most likely a shared bed phrased in
* a way LABEL_PATTERNS_WITH_OTHERS does not yet cover.
*
* @param string $code zimmercode
* @param string $label zimmertext
* @param int $maxPax MaxPax
* @return bool
*/
public function needsReview(string $code, string $label, int $maxPax): bool
{
if ($this->resolve($code, $label, $maxPax, $maxPax) !== Room::ROOM_TYPE_FOR_ONE) {
return false;
}
if ($maxPax !== 1) {
return false;
}
return !$this->matchesAny($label, static::LABEL_PATTERNS_SINGLE_USE);
}
/**
* @param string $label
* @param array $patterns
* @return bool
*/
protected function matchesAny(string $label, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (mb_strpos(mb_strtolower($label), mb_strtolower($pattern)) !== false) {
return true;
}
}
return false;
}
}
@@ -31,6 +31,7 @@ use EP\EpProducts\Domain\Model\SearchResult;
use EP\EpProducts\Domain\Repository\ProductRepository; use EP\EpProducts\Domain\Repository\ProductRepository;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class SearchResultService implements SingletonInterface class SearchResultService implements SingletonInterface
{ {
@@ -216,8 +217,10 @@ class SearchResultService implements SingletonInterface
$item['filterMetadata']['boardTypes'][] = $boardType; $item['filterMetadata']['boardTypes'][] = $boardType;
} }
if (isset($row['roomType']) && !\in_array($row['roomType'], $item['rooms'], false)) { foreach (GeneralUtility::trimExplode(',', (string) ($row['roomTypes'] ?? ''), true) as $roomType) {
$item['rooms'][] = $row['roomType']; if (!\in_array($roomType, $item['rooms'], false)) {
$item['rooms'][] = $roomType;
}
} }
if (!\in_array($row['nights'], $item['nights'], false)) { if (!\in_array($row['nights'], $item['nights'], false)) {
@@ -33,7 +33,10 @@ use EP\EpProducts\Domain\Repository\RegionRepository;
use EP\EpProducts\Domain\Repository\SnowreportRepository; use EP\EpProducts\Domain\Repository\SnowreportRepository;
use EP\EpProducts\Domain\Repository\WebcamRepository; use EP\EpProducts\Domain\Repository\WebcamRepository;
use EP\EpProducts\Utility\SettingsUtility; use EP\EpProducts\Utility\SettingsUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface; use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;
@@ -44,14 +47,34 @@ class SnowreportImportService implements SingletonInterface
const XML_FILE_URL = 'https://www.skiresort-service.com/xml-feed/'; const XML_FILE_URL = 'https://www.skiresort-service.com/xml-feed/';
/** /**
* @var array * Connection and read timeout for the feed download, in seconds. A healthy response to this
* endpoint arrives in well under a second, so the ceiling is kept low enough that an outage on
* the vendor side fails visibly instead of stalling the scheduler task or the backend request.
*/ */
protected static $validSnowreportUids = []; const HTTP_CONNECT_TIMEOUT = 5;
const HTTP_TIMEOUT = 30;
/** /**
* @var array * @var array
*/ */
protected static $validWebcamUids = []; protected $validSnowreportUids = [];
/**
* @var array
*/
protected $validWebcamUids = [];
/**
* Existing records keyed by vendor_uid, preloaded once per import to avoid a query per record.
*
* @var array
*/
protected $snowreportIndex = [];
/**
* @var array
*/
protected $webcamIndex = [];
/** /**
* @var \TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager * @var \TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager
@@ -91,27 +114,62 @@ class SnowreportImportService implements SingletonInterface
$this->regionRepository = $regionRepository; $this->regionRepository = $regionRepository;
} }
const TYPE_INT = 'int';
const TYPE_STRING = 'string';
const TYPE_BOOL = 'bool';
const TYPE_DATE = 'date';
/** /**
* Declared schema of the vendor feed: XML element name => target column and its type.
*
* XML carries no type information - every element is a string - so the type of each field is
* declared here rather than guessed from the value. Values are converted strictly according to
* this table; anything unexpected is logged and falls back to the type's empty value instead of
* silently changing type.
*
* @var array * @var array
*/ */
protected static $map = [ protected static $map = [
'region_id' => 'vendor_uid', 'region_id' => ['column' => 'vendor_uid', 'type' => self::TYPE_INT],
'region_name' => 'region_name', 'region_name' => ['column' => 'region_name', 'type' => self::TYPE_STRING],
'region_offen' => 'opened', 'region_offen' => ['column' => 'opened', 'type' => self::TYPE_BOOL],
'gletschergebiet' => 'glacier', 'gletschergebiet' => ['column' => 'glacier', 'type' => self::TYPE_BOOL],
'schneehoehe_berg' => 'snow_level_mountain', 'schneehoehe_berg' => ['column' => 'snow_level_mountain', 'type' => self::TYPE_INT],
'schneehoehe_tal' => 'snow_level_valley', 'schneehoehe_tal' => ['column' => 'snow_level_valley', 'type' => self::TYPE_INT],
'schneequalitaet' => 'snow_quality', 'schneequalitaet' => ['column' => 'snow_quality', 'type' => self::TYPE_STRING],
'letzter_schneefall' => 'last_snowfall', 'letzter_schneefall' => ['column' => 'last_snowfall', 'type' => self::TYPE_DATE],
'lifte_gesamt' => 'lifts_total', 'lifte_gesamt' => ['column' => 'lifts_total', 'type' => self::TYPE_INT],
'offene_lifte' => 'lifts_opened', 'offene_lifte' => ['column' => 'lifts_opened', 'type' => self::TYPE_INT],
'letzte_aktualisierung' => 'last_update', 'letzte_aktualisierung' => ['column' => 'last_update', 'type' => self::TYPE_DATE],
'hoehemax' => 'height_max', 'hoehemax' => ['column' => 'height_max', 'type' => self::TYPE_INT],
'hoehemin' => 'height_min', 'hoehemin' => ['column' => 'height_min', 'type' => self::TYPE_INT],
'datum_saisonstart' => 'season_start', 'datum_saisonstart' => ['column' => 'season_start', 'type' => self::TYPE_DATE],
'datum_saisonende' => 'season_end', 'datum_saisonende' => ['column' => 'season_end', 'type' => self::TYPE_DATE],
]; ];
/**
* Accepted boolean representations, lowercased.
*
* @var array
*/
protected static $booleanMap = [
'true' => true,
'1' => true,
'ja' => true,
'yes' => true,
'false' => false,
'0' => false,
'nein' => false,
'no' => false,
];
/**
* Distinct conversion problems encountered during the current import.
*
* @var array
*/
protected $conversionWarnings = [];
/** /**
* @return int * @return int
*/ */
@@ -119,39 +177,41 @@ class SnowreportImportService implements SingletonInterface
{ {
$count = 0; $count = 0;
$file = GeneralUtility::getFileAbsFileName(self::XML_FILE_PATH); $file = GeneralUtility::getFileAbsFileName(self::XML_FILE_PATH);
$xmlContent = file_get_contents($file);
$xmlData = simplexml_load_string($xmlContent); if (!is_file($file)) {
$this->writelog(sprintf('Snowreport XML import failed: file "%s" does not exist.', $file));
return 0;
}
try {
$xmlData = $this->parseReportsXml((string) file_get_contents($file));
} catch (\RuntimeException $e) {
$this->writelog(sprintf('Snowreport XML import failed: %s', $e->getMessage()));
return 0;
}
$this->resetImportState();
$this->buildIndexes();
$snowReportStoragePid = $this->getStoragePid(); $snowReportStoragePid = $this->getStoragePid();
foreach ($xmlData->schneemeldung as $reportXml) foreach ($xmlData->schneemeldung as $reportXml)
{ {
$vendorUid = (int) $reportXml->region_id; $vendorUid = (int) $reportXml->region_id;
$result = $this->snowreportRepository->findByVendorUid($vendorUid); $snowreport = $this->getSnowreport($vendorUid);
if ($result->count() === 0) {
$snowreport = new Snowreport();
$this->snowreportRepository->add($snowreport);
} else {
$snowreport = $result->getFirst();
}
$row = []; $row = [];
foreach (static::$map as $key => $column) foreach (static::$map as $key => $field)
{ {
$valueRaw = trim($reportXml->{$key}); if (!isset($reportXml->{$key})) {
$this->addConversionWarning(sprintf('Element <%s> missing in feed.', $key));
if ($valueRaw === 'true') { $row[$field['column']] = $this->getEmptyValue($field['type']);
$value = true; continue;
} elseif ($valueRaw === 'false') {
$value = false;
} elseif (preg_match('/^\d{4}\-\d{2}\-\d{2}$/', $valueRaw)) {
$date = new \DateTime($valueRaw);
$value = $date->getTimestamp();
} else {
$value = (string) $valueRaw;
} }
$row[$column] = $value; $row[$field['column']] = $this->convertValue((string) $reportXml->{$key}, $field['type'], $key);
} }
$snowreport->setPid($snowReportStoragePid); $snowreport->setPid($snowReportStoragePid);
@@ -175,23 +235,24 @@ class SnowreportImportService implements SingletonInterface
$this->importWebcamData($reportXml->webcam, $snowreport); $this->importWebcamData($reportXml->webcam, $snowreport);
} }
if ($snowreport->_isNew()) { // New objects are already registered via add(); only managed ones need update().
$this->persistenceManager->persistAll(); if (!$snowreport->_isNew()) {
} else {
$this->snowreportRepository->update($snowreport); $this->snowreportRepository->update($snowreport);
} }
static::$validSnowreportUids[] = $vendorUid; $this->validSnowreportUids[] = $vendorUid;
$count++; $count++;
} }
$this->persistenceManager->persistAll(); $this->persistenceManager->persistAll();
$this->cleanupData('tx_epproducts_domain_model_webcam', static::$validWebcamUids); $this->cleanupData('tx_epproducts_domain_model_webcam', $this->validWebcamUids);
$this->cleanupData('tx_epproducts_domain_model_snowreport', static::$validSnowreportUids); $this->cleanupData('tx_epproducts_domain_model_snowreport', $this->validSnowreportUids);
$this->updateWebcamCounters();
$GLOBALS['BE_USER']->writelog(4, 0, 0, 0, sprintf('Snowreport XML import successful (%d records).', $count), []); $this->logConversionWarnings();
$this->writelog(sprintf('Snowreport XML import successful (%d records).', $count));
return $count; return $count;
} }
@@ -205,40 +266,52 @@ class SnowreportImportService implements SingletonInterface
$snowReportStoragePid = $this->getStoragePid(); $snowReportStoragePid = $this->getStoragePid();
foreach ($xml->children() as $item) foreach ($xml->children() as $item)
{ {
$include = (string) $item->aktuell === 'true'; $include = $this->convertValue((string) $item->aktuell, self::TYPE_BOOL, 'aktuell');
$available = (string) $item->verf === 'true'; $available = $this->convertValue((string) $item->verf, self::TYPE_BOOL, 'verf');
$vendorUid = (int) $item->uid; $vendorUid = (int) $item->uid;
$result = $this->webcamRepository->findByVendorUid($vendorUid);
// Skip invalid cams $webcam = isset($this->webcamIndex[$vendorUid]) ? $this->webcamIndex[$vendorUid] : null;
if ($include === false && $result->count() === 0) {
// Skip cams that are neither current nor already known
if (null === $webcam && false === $include) {
continue; continue;
} }
// Add new cam if not in db yet // Add new cam if not in db yet
if ($result->count() === 0) { if (null === $webcam) {
$webcam = new Webcam(); $webcam = new Webcam();
$webcam->setVendorUid($vendorUid); $webcam->setVendorUid($vendorUid);
$webcam->setPid($snowReportStoragePid); $webcam->setPid($snowReportStoragePid);
$webcam->setUrl((string) $item->url);
$webcam->setName((string) $item->dt);
$webcam->setAvailable($available);
$this->webcamRepository->add($webcam); $this->webcamRepository->add($webcam);
$this->webcamIndex[$vendorUid] = $webcam;
$snowreport->addWebcam($webcam); $snowreport->addWebcam($webcam);
} else {
// Update cam in db and set availability
$webcam = $result->getFirst();
$webcam->setUrl((string) $item->url);
$webcam->setName((string) $item->dt);
$webcam->setAvailable($available && $include);
} }
static::$validWebcamUids[] = $vendorUid;
$webcam->setUrl((string) $item->url);
$webcam->setName((string) $item->dt);
$webcam->setAvailable($available && $include);
$this->validWebcamUids[] = $vendorUid;
} }
} }
/**
* Flags records that are no longer contained in the feed as deleted.
*
* @param string $table
* @param array $validUids
* @return int Number of records flagged
*/
public function cleanupData($table, array $validUids) public function cleanupData($table, array $validUids)
{ {
/** @var ConnectionPool $connectionPool */ /** @var ConnectionPool $connectionPool */
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
$qb = $connectionPool->getQueryBuilderForTable($table); $qb = $connectionPool->getQueryBuilderForTable($table);
// Hidden records are still managed by the import, only deleted ones are out of scope.
$qb->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
;
$result = $qb $result = $qb
->select('vendor_uid') ->select('vendor_uid')
->from($table) ->from($table)
@@ -250,36 +323,293 @@ class SnowreportImportService implements SingletonInterface
return (int)$row['vendor_uid']; return (int)$row['vendor_uid'];
}, $result); }, $result);
$obsoleteUids = array_diff($currentUids, $validUids); $obsoleteUids = array_values(array_diff($currentUids, $validUids));
if (0 === count($obsoleteUids)) { if (0 === count($obsoleteUids)) {
return; return 0;
} }
$qb = $connectionPool->getQueryBuilderForTable($table); $qb = $connectionPool->getQueryBuilderForTable($table);
$qb
->delete($table) return (int) $qb
->where($qb->expr()->in('vendor_uid', $obsoleteUids)) ->update($table)
->set('deleted', 1)
->set('tstamp', isset($GLOBALS['EXEC_TIME']) ? $GLOBALS['EXEC_TIME'] : time())
->where(
$qb->expr()->in(
'vendor_uid',
$qb->createNamedParameter($obsoleteUids, Connection::PARAM_INT_ARRAY)
)
)
->execute() ->execute()
; ;
} }
/**
* Recalculates the inline child counter on the snowreport records. Webcams are flagged as deleted
* with plain SQL, which bypasses the counter Extbase maintains for the relation.
*/
protected function updateWebcamCounters()
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tx_epproducts_domain_model_snowreport');
$connection->executeStatement(
'UPDATE tx_epproducts_domain_model_snowreport s SET s.webcams = ('
. ' SELECT COUNT(*) FROM tx_epproducts_domain_model_webcam w'
. ' WHERE w.snowreport = s.uid AND w.deleted = 0'
. ')'
);
}
/**
* Downloads the feed and replaces the local file only if the response is a usable XML document.
* The existing file is left untouched on any failure.
*
* @return bool
*/
public function downloadReportsXml() public function downloadReportsXml()
{ {
$path = GeneralUtility::getFileAbsFileName(self::XML_FILE_PATH); $path = GeneralUtility::getFileAbsFileName(self::XML_FILE_PATH);
$backupPath = $path . '.bak';
@copy($path, $backupPath); try {
@unlink($path); $xmlContent = $this->fetchReportsXml();
$this->parseReportsXml($xmlContent);
} catch (\Exception $e) {
$this->writelog(sprintf(
'Snowreport XML download failed (%s). Keeping existing file.',
$e->getMessage()
));
$url = self::XML_FILE_URL; return false;
exec("wget -O {$path} {$url}", $output, $return); }
if (!$return) {
@unlink($backupPath); // Write to a temporary file first and move it into place, so the feed is either the old or the
// new document, never a partially written one.
$temporaryPath = $path . '.tmp';
if (false === file_put_contents($temporaryPath, $xmlContent) || !rename($temporaryPath, $path)) {
@unlink($temporaryPath);
$this->writelog(sprintf('Snowreport XML could not be written to "%s".', $path));
return false;
}
return true;
}
/**
* @return string
* @throws \RuntimeException
*/
protected function fetchReportsXml()
{
/** @var RequestFactory $requestFactory */
$requestFactory = GeneralUtility::makeInstance(RequestFactory::class);
$response = $requestFactory->request(self::XML_FILE_URL, 'GET', [
'connect_timeout' => self::HTTP_CONNECT_TIMEOUT,
'timeout' => self::HTTP_TIMEOUT,
'headers' => [
'Accept' => 'application/xml, text/xml',
],
]);
if (200 !== $response->getStatusCode()) {
throw new \RuntimeException(
sprintf('unexpected status code %d', $response->getStatusCode()),
1758000001
);
}
return (string) $response->getBody();
}
/**
* Parses the feed and rejects documents that are unusable. Without this an empty or truncated
* response would be imported as an empty feed, and the cleanup would flag every record as deleted.
*
* @param string $xmlContent
* @return \SimpleXMLElement
* @throws \RuntimeException
*/
protected function parseReportsXml($xmlContent)
{
if ('' === trim($xmlContent)) {
throw new \RuntimeException('response is empty', 1758000002);
}
$useInternalErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
$xmlData = simplexml_load_string($xmlContent);
$errors = libxml_get_errors();
libxml_clear_errors();
libxml_use_internal_errors($useInternalErrors);
if (false === $xmlData) {
$message = count($errors) > 0 ? trim($errors[0]->message) : 'unknown error';
throw new \RuntimeException(sprintf('response is not valid XML (%s)', $message), 1758000003);
}
if (0 === $xmlData->schneemeldung->count()) {
throw new \RuntimeException('response contains no <schneemeldung> elements', 1758000004);
}
return $xmlData;
}
/**
* Loads existing records once, keyed by vendor_uid. Replaces a repository lookup per feed record.
*/
protected function buildIndexes()
{
$this->snowreportIndex = [];
foreach ($this->snowreportRepository->findAll() as $snowreport) {
$this->snowreportIndex[(int) $snowreport->getVendorUid()] = $snowreport;
}
$this->webcamIndex = [];
foreach ($this->webcamRepository->findAll() as $webcam) {
$this->webcamIndex[(int) $webcam->getVendorUid()] = $webcam;
}
}
/**
* @param int $vendorUid
* @return Snowreport
*/
protected function getSnowreport($vendorUid)
{
if (isset($this->snowreportIndex[$vendorUid])) {
return $this->snowreportIndex[$vendorUid];
}
$snowreport = new Snowreport();
$snowreport->setVendorUid($vendorUid);
$this->snowreportRepository->add($snowreport);
$this->snowreportIndex[$vendorUid] = $snowreport;
return $snowreport;
}
/**
* The service is a singleton, so per-import state must not survive into the next run.
*/
protected function resetImportState()
{
$this->validSnowreportUids = [];
$this->validWebcamUids = [];
$this->conversionWarnings = [];
}
/**
* Converts a raw XML string to the declared type of the field.
*
* @param string $raw
* @param string $type
* @param string $key XML element name, for logging
* @return int|string|bool
*/
protected function convertValue($raw, $type, $key)
{
$raw = trim($raw);
// The feed delivers empty elements for data that is not available yet (snow levels out of
// season, for example). That is expected, not an error.
if ($raw === '') {
return $this->getEmptyValue($type);
}
switch ($type) {
case self::TYPE_STRING:
return $raw;
case self::TYPE_INT:
if (!preg_match('/^-?\d+$/', $raw)) {
$this->addConversionWarning(sprintf('<%s> expected an integer, got "%s".', $key, $raw));
return 0;
}
return (int) $raw;
case self::TYPE_BOOL:
$normalized = strtolower($raw);
if (!array_key_exists($normalized, static::$booleanMap)) {
$this->addConversionWarning(sprintf('<%s> expected a boolean, got "%s".', $key, $raw));
return false;
}
return static::$booleanMap[$normalized];
case self::TYPE_DATE:
if (!preg_match('/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$/', $raw)) {
$this->addConversionWarning(sprintf('<%s> expected a date, got "%s".', $key, $raw));
return 0;
}
$date = new \DateTime($raw);
return $date->getTimestamp();
}
throw new \InvalidArgumentException(sprintf('Unknown field type "%s" declared for <%s>.', $type, $key), 1758000000);
}
/**
* @param string $type
* @return int|string|bool
*/
protected function getEmptyValue($type)
{
if ($type === self::TYPE_STRING) {
return '';
}
if ($type === self::TYPE_BOOL) {
return false;
}
return 0;
}
/**
* @param string $message
*/
protected function addConversionWarning($message)
{
// Keyed to keep one entry per distinct problem instead of one per record.
$this->conversionWarnings[$message] = true;
}
/**
* Writes a single summary entry to the log if the feed contained unexpected values.
*/
protected function logConversionWarnings()
{
if (0 === count($this->conversionWarnings)) {
return; return;
} }
$GLOBALS['BE_USER']->writelog(4, 0, 0, 0, 'Snowreport XML download failed. Keeping existing file.', []); $messages = array_keys($this->conversionWarnings);
@rename($backupPath, $path); $this->writelog(sprintf(
'Snowreport XML import: %d unexpected value(s) in feed: %s',
count($messages),
implode(' | ', array_slice($messages, 0, 10))
));
$this->conversionWarnings = [];
}
/**
* The scheduler task and the backend module provide a backend user, the CLI command does not.
*
* @param string $message
*/
protected function writelog($message)
{
if (isset($GLOBALS['BE_USER']) && is_object($GLOBALS['BE_USER'])) {
$GLOBALS['BE_USER']->writelog(4, 0, 0, 0, $message, []);
}
} }
public function getStoragePid() public function getStoragePid()
@@ -1,144 +0,0 @@
<?php
return [
'ctrl' => [
'title' => 'LLL:EXT:ep_products/Resources/Private/Language/locallang.xlf:tx_epproducts_domain_model_roommapping',
'label' => 'code',
'prependAtCopy' => '[COPY %s]',
'default_sortby' => 'ORDER BY code',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'cruser_id' => 'cruser_id',
'dividers2tabs' => true,
'versioningWS' => false,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'delete' => 'deleted',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'code',
'iconfile' => 'EXT:ep_theme/Resources/Public/images/icon_ce.svg',
],
'types' => [
'1' => ['showitem' => 'code, type'],
],
'palettes' => [
'1' => ['showitem' => ''],
],
'columns' => [
'sys_language_uid' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title',
'items' => [
['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.allLanguages', -1],
['LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.default_value', 0]
],
],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle',
'items' => [
['', 0],
],
'foreign_table' => 'tx_epproducts_domain_model_roommapping',
'foreign_table_where' => 'AND tx_epproducts_domain_model_roommapping.pid=###CURRENT_PID### AND tx_epproducts_domain_model_roommapping.sys_language_uid IN (-1,0)',
],
],
'l10n_diffsource' => [
'config' => [
'type' => 'passthrough',
],
],
't3ver_label' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.versionLabel',
'config' => [
'type' => 'input',
'size' => 30,
'max' => 255,
]
],
'hidden' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => [
'type' => 'check',
],
],
'starttime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
'config' => [
'type' => 'input',
'size' => 13,
'eval' => 'datetime',
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
'renderType' => 'inputDateTime',
],
],
'endtime' => [
'exclude' => 0,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
'config' => [
'type' => 'input',
'size' => 13,
'eval' => 'datetime',
'checkbox' => 0,
'default' => 0,
'behaviour' => [
'allowLanguageSynchronization' => true,
],
'renderType' => 'inputDateTime',
],
],
'code' => [
'exclude' => 0,
'label' => 'Code',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim'
],
],
'type' => [
'exclude' => 0,
'label' => 'Typ',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['Einzelzimmer', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_ONE],
['Doppelzimmer und Apartm. f. 2 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_TWO],
['Zimmer und Apartm. f. 3 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_THREE],
['Zimmer und Apartm. f. 4 Pers.', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_FOR_FOUR],
['Zimmer und Apartm. mit Anderen', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_WITH_OTHERS],
['weitere Zimmer und Apartments', \EP\EpProducts\Domain\Model\Room::ROOM_TYPE_OTHER],
],
'size' => 1,
'minItems' => 1,
'maxItems' => 1
],
],
],
];
@@ -39,9 +39,6 @@
<trans-unit id="tx_epproducts_domain_model_teammember"> <trans-unit id="tx_epproducts_domain_model_teammember">
<source>Mitarbeiter/Teamer</source> <source>Mitarbeiter/Teamer</source>
</trans-unit> </trans-unit>
<trans-unit id="tx_epproducts_domain_model_roommapping">
<source>Zimmermapping</source>
</trans-unit>
<trans-unit id="tx_epproducts_domain_model_journey"> <trans-unit id="tx_epproducts_domain_model_journey">
<source>Anreise</source> <source>Anreise</source>
</trans-unit> </trans-unit>
@@ -494,47 +494,6 @@ CREATE TABLE tx_epproducts_domain_model_room
KEY constraints (date, type) KEY constraints (date, type)
); );
#
# Table structure for table 'tx_epproducts_domain_model_roommapping'
#
CREATE TABLE tx_epproducts_domain_model_roommapping
(
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
code varchar(255) DEFAULT '' NOT NULL,
type int(11) DEFAULT '0' NOT NULL,
tstamp int(11) unsigned DEFAULT '0' NOT NULL,
crdate int(11) unsigned DEFAULT '0' NOT NULL,
cruser_id int(11) unsigned DEFAULT '0' NOT NULL,
deleted tinyint(4) unsigned DEFAULT '0' NOT NULL,
hidden tinyint(4) unsigned DEFAULT '0' NOT NULL,
starttime int(11) unsigned DEFAULT '0' NOT NULL,
endtime int(11) unsigned DEFAULT '0' NOT NULL,
t3ver_oid int(11) DEFAULT '0' NOT NULL,
t3ver_id int(11) DEFAULT '0' NOT NULL,
t3ver_wsid int(11) DEFAULT '0' NOT NULL,
t3ver_label varchar(255) DEFAULT '' NOT NULL,
t3ver_state tinyint(4) DEFAULT '0' NOT NULL,
t3ver_stage int(11) DEFAULT '0' NOT NULL,
t3ver_count int(11) DEFAULT '0' NOT NULL,
t3ver_tstamp int(11) DEFAULT '0' NOT NULL,
t3ver_move_id int(11) DEFAULT '0' NOT NULL,
sys_language_uid int(11) DEFAULT '0' NOT NULL,
l10n_parent int(11) DEFAULT '0' NOT NULL,
l10n_diffsource mediumblob,
PRIMARY KEY (uid),
KEY parent (pid),
KEY t3ver_oid (t3ver_oid, t3ver_wsid),
KEY language (l10n_parent, sys_language_uid)
);
# #
# Table structure for table 'tx_epproducts_domain_model_concept' # Table structure for table 'tx_epproducts_domain_model_concept'
# #
@@ -1098,6 +1057,7 @@ CREATE TABLE tx_epproducts_domain_model_snowreport
PRIMARY KEY (uid), PRIMARY KEY (uid),
KEY parent (pid), KEY parent (pid),
KEY vendor_uid (vendor_uid),
KEY t3ver_oid (t3ver_oid, t3ver_wsid), KEY t3ver_oid (t3ver_oid, t3ver_wsid),
KEY language (l10n_parent, sys_language_uid) KEY language (l10n_parent, sys_language_uid)
@@ -1142,6 +1102,7 @@ CREATE TABLE tx_epproducts_domain_model_webcam
PRIMARY KEY (uid), PRIMARY KEY (uid),
KEY parent (pid), KEY parent (pid),
KEY vendor_uid (vendor_uid),
KEY t3ver_oid (t3ver_oid, t3ver_wsid), KEY t3ver_oid (t3ver_oid, t3ver_wsid),
KEY language (l10n_parent, sys_language_uid) KEY language (l10n_parent, sys_language_uid)
@@ -4,25 +4,43 @@ namespace EP\EpTheme\Form;
use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher; use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
abstract class AbstractMappedFinisher extends AbstractFinisher abstract class AbstractMappedFinisher extends AbstractFinisher
{ {
protected function buildPayload(): array /**
* Resolves the 'mapping' option into remote name => local form element identifier.
*/
protected function parseMapping(): array
{ {
$values = []; $mappings = [];
$formValues = $this->finisherContext->getFormValues();
$mappingOption = (string)$this->parseOption('mapping'); $mappingOption = (string)$this->parseOption('mapping');
$mappings = GeneralUtility::trimExplode(',', $mappingOption, true);
foreach ($mappings as $mapping) { foreach (GeneralUtility::trimExplode(',', $mappingOption, true) as $mapping) {
$parts = GeneralUtility::trimExplode(':', $mapping, true, 2); $parts = GeneralUtility::trimExplode(':', $mapping, true, 2);
if (2 !== count($parts)) { if (2 !== count($parts)) {
continue; continue;
} }
[$remote, $local] = $parts; [$remote, $local] = $parts;
if ('' === $remote || '' === $local || false === array_key_exists($local, $formValues)) { if ('' === $remote || '' === $local) {
continue;
}
$mappings[$remote] = $local;
}
return $mappings;
}
protected function buildPayload(): array
{
$values = [];
$formValues = $this->finisherContext->getFormValues();
foreach ($this->parseMapping() as $remote => $local) {
if (false === array_key_exists($local, $formValues)) {
continue; continue;
} }
@@ -32,6 +50,95 @@ abstract class AbstractMappedFinisher extends AbstractFinisher
return $values; return $values;
} }
/**
* Every submitted value that the mapping does not already cover, keyed by the element label
* as the editor sees it in the form editor. Forms built in the backend use generated
* identifiers (text-1, singleselect-2, ...) that carry no meaning and cannot be renamed
* without breaking existing finisher options, so the label is the only stable description
* of what was asked.
*
* The label is taken from the form definition, not through TranslationService: a translated
* label would change the payload keys with the site language, and these keys are stored.
*/
protected function buildLabeledValues(): array
{
$formValues = $this->finisherContext->getFormValues();
$skip = array_merge(
array_values($this->parseMapping()),
GeneralUtility::trimExplode(',', (string)$this->parseOption('exclude'), true)
);
// Keyed by identifier in form order, and contains only real form elements - iterating
// this instead of the form values keeps the collection in the order of the form and
// leaves out anything that is not an element the visitor filled in.
$elements = $this->finisherContext->getFormRuntime()->getFormDefinition()->getElements();
$collected = [];
foreach ($elements as $identifier => $element) {
if (true === in_array($identifier, $skip, true) || false === array_key_exists($identifier, $formValues)) {
continue;
}
$value = $this->normalizeValue($formValues[$identifier], $identifier);
if (null === $value || '' === $value || [] === $value) {
continue;
}
$label = trim($element->getLabel());
if ('' === $label) {
$label = $identifier;
}
if (true === array_key_exists($label, $collected)) {
$label .= ' (' . $identifier . ')';
}
$collected[$label] = $value;
}
return $collected;
}
/**
* @param mixed $value
*
* @return mixed null when the value has no JSON representation
*/
protected function normalizeValue($value, string $identifier)
{
if (true === is_array($value)) {
$normalized = [];
foreach ($value as $item) {
$item = $this->normalizeValue($item, $identifier);
if (null !== $item && '' !== $item) {
$normalized[] = $item;
}
}
return $normalized;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
if ($value instanceof FileReference) {
$resource = $value->getOriginalResource();
return null !== $resource ? $resource->getName() : null;
}
if (true === is_object($value)) {
$this->logWarning('Form value skipped while collecting labeled values: unsupported type.', [
'element' => $identifier,
'class' => get_class($value),
]);
return null;
}
return $value;
}
protected function logWarning(string $message, array $context = []): void protected function logWarning(string $message, array $context = []): void
{ {
GeneralUtility::makeInstance(LogManager::class) GeneralUtility::makeInstance(LogManager::class)
@@ -0,0 +1,83 @@
<?php
namespace EP\EpTheme\Form;
use EP\EpProducts\MyEP\ApiClient;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class ApplicationApiFinisher extends AbstractMappedFinisher
{
/**
* @var ApiClient
*/
protected $apiClient;
/**
* @param ApiClient $apiClient
*/
public function injectApiClient(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
protected function executeInternal()
{
try {
$payload = $this->buildPayload();
if ([] === $payload) {
$this->logWarning('Application API finisher skipped: the mapping produced no values.');
return;
}
$formDefinition = $this->finisherContext->getFormRuntime()->getFormDefinition();
$formLabel = trim($formDefinition->getLabel());
$payload['form'] = $formDefinition->getIdentifier();
// The name the editor gave the form, so the receiving side can label a submission
// without keeping a list of identifiers. Taken from the definition rather than
// through TranslationService: a translated label would differ per site language.
$payload['formLabel'] = '' !== $formLabel ? $formLabel : $formDefinition->getIdentifier();
$payload['submittedAt'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
$payload = array_merge($payload, $this->resolvePageContext());
$payload['details'] = $this->buildLabeledValues();
$this->apiClient->submitApplication($payload);
} catch (\Throwable $e) {
$this->logWarning('Application API finisher request failed.', [
'exception' => $e,
]);
}
}
/**
* The page the form was submitted from. The URL is generated through the site router in the
* visitor's language, the same way AddReferrerFinisher does it, so it is the page's canonical
* URL rather than the submit request with its form arguments attached.
*/
private function resolvePageContext(): array
{
if (false === isset($GLOBALS['TSFE']) || 0 === (int)$GLOBALS['TSFE']->id) {
return [];
}
$pageUid = (int)$GLOBALS['TSFE']->id;
$context = ['pageUid' => $pageUid];
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$context['pageUrl'] = (string)$site->getRouter()->generateUri($pageUid, [
'_language' => $GLOBALS['TSFE']->getLanguage(),
]);
} catch (\Throwable $e) {
// An unroutable page must not cost the submission, so the uid is sent without a URL.
$this->logWarning('Application API finisher could not resolve the page URL.', [
'pageUid' => $pageUid,
'exception' => $e,
]);
}
return $context;
}
}
@@ -1,49 +0,0 @@
<?php
namespace EP\EpTheme\ViewHelpers;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\ViewHelpers\ImageViewHelper;
class LazyLoadImageViewHelper extends ImageViewHelper
{
public function initializeArguments()
{
parent::initializeArguments();
$this->registerArgument('loaderimg', 'string', '', false);
}
public function render(): string
{
parent::render();
$src = $this->tag->getAttribute('src');
$this->tag->removeAttribute('src');
$this->tag->addAttribute('data', ['src' => $src], false);
if (isset($this->arguments['loaderimg'])) {
$loaderImage = $this->imageService->getImage($this->arguments['loaderimg'], null, false);
$processingInstructions = [
'width' => $this->arguments['width'],
'height' => $this->arguments['height'],
'minWidth' => $this->arguments['minWidth'],
'minHeight' => $this->arguments['minHeight'],
'maxWidth' => $this->arguments['maxWidth'],
'maxHeight' => $this->arguments['maxHeight'],
];
$processedLoaderImage = $this->imageService->applyProcessingInstructions($loaderImage, $processingInstructions);
$loaderImageUri = $this->imageService->getImageUri($processedLoaderImage, $this->arguments['absolute']);
$this->tag->addAttribute('src', $loaderImageUri);
}
$classes = $this->tag->getAttribute('class');
$classItems = GeneralUtility::trimExplode(' ', $classes, true);
$classItems[] = 'lazyload';
$classes = implode(' ', array_unique($classItems));
$this->tag->addAttribute('class', $classes);
// ensure a11y compliance
$this->tag->removeAttribute('title');
return $this->tag->render();
}
}
@@ -17,5 +17,8 @@ plugin.tx_epproducts {
ratingWidgetLabel = {$plugin.tx_eptheme.settings.ratingWidgetLabel} ratingWidgetLabel = {$plugin.tx_eptheme.settings.ratingWidgetLabel}
skipassPageUid = {$plugin.tx_eptheme.settings.skipassPageUid} skipassPageUid = {$plugin.tx_eptheme.settings.skipassPageUid}
conceptsPageUid = {$plugin.tx_eptheme.settings.conceptsPageUid} conceptsPageUid = {$plugin.tx_eptheme.settings.conceptsPageUid}
mapTileUrl = {$plugin.tx_eptheme.settings.mapTileUrl}
mapApiKey = {$plugin.tx_eptheme.settings.mapApiKey}
mapAttribution = {$plugin.tx_eptheme.settings.mapAttribution}
} }
} }
@@ -7,5 +7,8 @@ lib.contentElement {
settings < plugin.tx_eptheme.settings settings < plugin.tx_eptheme.settings
settings.themekey = {$plugin.tx_eptheme.settings.themekey} settings.themekey = {$plugin.tx_eptheme.settings.themekey}
settings.defaultAjaxUid = {$plugin.tx_eptheme.settings.defaultAjaxUid} settings.defaultAjaxUid = {$plugin.tx_eptheme.settings.defaultAjaxUid}
settings.mapTileUrl = {$plugin.tx_eptheme.settings.mapTileUrl}
settings.mapApiKey = {$plugin.tx_eptheme.settings.mapApiKey}
settings.mapAttribution = {$plugin.tx_eptheme.settings.mapAttribution}
} }
@@ -15,3 +15,10 @@ module.tx_form.settings.yamlConfigurations {
1630994710 = EXT:ep_theme/Configuration/Yaml/FormsBase.yaml 1630994710 = EXT:ep_theme/Configuration/Yaml/FormsBase.yaml
1630994711 = EXT:ep_theme/Configuration/Yaml/FormFinishersBackend.yaml 1630994711 = EXT:ep_theme/Configuration/Yaml/FormFinishersBackend.yaml
} }
plugin.tx_bwcaptcha.settings {
charset = abcdefghjkmnpqrtuvwxy34679
length = 4
width = 220
height = 60
}
@@ -1,10 +1,20 @@
config.tx_replacer { config.tx_replacer {
enable_regex = 1 enable_regex = 1
# The quote character is captured rather than matched literally, because the
# @font-face block in Page/Main.typoscript writes its url() values with single
# quotes - anchoring on `"` alone would skip every font.
# typo3conf is scoped to ep_theme/Resources/Public on purpose: it keeps other
# extensions, Configuration/ and Resources/Private/ out of the CDN.
# The icon sprite is excluded: SVG <use href="...#icon"> may only reference a
# document from the same origin - browsers block an external sprite on the CDN
# host outright, and no CORS header on bunny.net changes that. It has to stay
# on the origin domain or every icon renders empty.
search { search {
10 = /"\/?(fileadmin|typo3temp|uploads)/ 10 = /(["'])\/?(fileadmin|typo3temp|uploads|typo3conf\/ext\/ep_theme\/Resources\/Public(?!\/images\/icons_sprite\.svg))/
} }
replace { replace {
10 = "https://cdn.ep-reisen.de/$1 10 = $1https://cdn.ep-reisen.de/$2
} }
} }
@@ -5,5 +5,6 @@ plugin.tx_typo3encore {
builds { builds {
EpThemeConfig = EXT:ep_theme/Resources/Public EpThemeConfig = EXT:ep_theme/Resources/Public
} }
preload.enable = 0
} }
} }
@@ -75,6 +75,13 @@ plugin.tx_eptheme {
# cat=eptheme/200/350; type=string; label='Corona Updates' page UID # cat=eptheme/200/350; type=string; label='Corona Updates' page UID
coronaUpdatesPageUid = coronaUpdatesPageUid =
# cat=eptheme/500/100; type=string; label=Map tile URL template (without apiKey)
mapTileUrl = https://api-eu.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png
# cat=eptheme/500/110; type=string; label=Map tile API key (Geoapify), restricted by referrer
mapApiKey =
# cat=eptheme/500/120; type=string; label=Map attribution (Geoapify and OpenStreetMap credits are both required)
mapAttribution = Powered by <a href="https://www.geoapify.com/">Geoapify</a> | &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors
# cat=eptheme/400/100; type=string; label=Phone number general # cat=eptheme/400/100; type=string; label=Phone number general
phoneNumber = 0221 - 272 276 0 phoneNumber = 0221 - 272 276 0
# cat=eptheme/400/110; type=string; label=Phone number WhatsApp # cat=eptheme/400/110; type=string; label=Phone number WhatsApp
@@ -23,6 +23,8 @@ TYPO3:
finishersDefinition: finishersDefinition:
BpnApiFinisher: BpnApiFinisher:
implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher' implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher'
ApplicationApiFinisher:
implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher'
WebhookFinisher: WebhookFinisher:
implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' implementationClassName: 'EP\EpTheme\Form\WebhookFinisher'
AddReferrerFinisher: AddReferrerFinisher:
@@ -12,6 +12,9 @@ TYPO3:
1000: 1000:
value: 'BpnApiFinisher' value: 'BpnApiFinisher'
label: 'BusPro Finisher' label: 'BusPro Finisher'
1010:
value: 'ApplicationApiFinisher'
label: 'Application Finisher'
1050: 1050:
value: 'WebhookFinisher' value: 'WebhookFinisher'
label: 'Webhook Finisher' label: 'Webhook Finisher'
@@ -34,6 +37,25 @@ TYPO3:
propertyPath: 'options.mapping' propertyPath: 'options.mapping'
propertyValidators: propertyValidators:
10: 'NotEmpty' 10: 'NotEmpty'
1010:
identifier: 'ApplicationApiFinisher'
editors:
__inheritances:
10: 'TYPO3.CMS.Form.mixins.formElementMixins.BaseCollectionEditorsMixin'
100:
label: 'Application Finisher'
110:
identifier: 'mapping'
templateName: 'Inspector-TextEditor'
label: 'Mapping'
propertyPath: 'options.mapping'
propertyValidators:
10: 'NotEmpty'
120:
identifier: 'exclude'
templateName: 'Inspector-TextEditor'
label: 'Exclude'
propertyPath: 'options.exclude'
1050: 1050:
identifier: 'WebhookFinisher' identifier: 'WebhookFinisher'
editors: editors:
@@ -70,6 +92,13 @@ TYPO3:
predefinedDefaults: predefinedDefaults:
options: options:
mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1'
ApplicationApiFinisher:
formEditor:
iconIdentifier: 'form-finisher'
predefinedDefaults:
options:
mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1'
exclude: 'captcha,privacypolicyaccepted'
WebhookFinisher: WebhookFinisher:
formEditor: formEditor:
iconIdentifier: 'form-finisher' iconIdentifier: 'form-finisher'
@@ -11,6 +11,14 @@ TYPO3:
options: options:
identifier: 'BpnApiFinisher' identifier: 'BpnApiFinisher'
mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1'
ApplicationApiFinisher:
implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher'
formEditor:
label: 'Application Finisher'
options:
identifier: 'ApplicationApiFinisher'
mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1'
exclude: 'captcha,privacypolicyaccepted'
WebhookFinisher: WebhookFinisher:
implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' implementationClassName: 'EP\EpTheme\Form\WebhookFinisher'
formEditor: formEditor:
@@ -1,8 +1,3 @@
// Import dependencies
require('lazysizes')
require('lazysizes/plugins/unveilhooks/ls.unveilhooks')
// require('picturefill')
// Polyfills // Polyfills
// import smoothscroll from 'smoothscroll-polyfill' // import smoothscroll from 'smoothscroll-polyfill'
// smoothscroll.polyfill() // smoothscroll.polyfill()
@@ -1,791 +0,0 @@
<template>
<div class="relative">
<div class="mb-8"
v-show="!submitted">
<label class="mb-2 font-bold">
Zeitraum
</label>
<input type="text"
class="form-field"
placeholder="von... bis" ref="picker"/>
<p class="text-sm mt-2" v-if="mainSeasonFrom && mainSeasonTo">
in der Hauptsaison {{ mainSeasonFrom }}-{{ mainSeasonTo }} mind. {{ minBookingDays }} Tage
</p>
<p class="text-sm italic mt-2"
v-show="nightsCount === 1">
Einzelne Nächte sind nur auf Anfrage buchbar
</p>
</div>
<div class="mb-8 grid sm:grid-cols-2 gap-8"
v-show="!submitted">
<div>
<label class="mb-2 font-bold">
Personenzahl
</label>
<input type="number"
class="form-field"
min="30"
v-model.number="selectedPax"
@change="onPaxUpdated()"
v-show="nightsCount > 0"/>
<p class="text-sm italic"
v-show="nightsCount === 0">
Bitte zuerst einen Zeitraum wählen
</p>
</div>
<div>
<label class="mb-2 font-bold">
davon Kinder 0-3 Jahre
</label>
<input type="number"
class="form-field"
min="0"
v-model.number="childrenCount"
@change="onPaxUpdated()"
v-show="nightsCount > 0"/>
<p class="text-sm italic"
v-show="nightsCount === 0">
Bitte zuerst einen Zeitraum wählen
</p>
<p class="text-sm italic"
v-show="nightsCount > 0">
Kinder werden nur bei Strom- und Abfallgebühren berücksichtigt.
</p>
</div>
<div>
<label class="mb-2 font-bold">
davon Kinder 4-5 Jahre*
</label>
<input type="number"
class="form-field"
min="0"
v-model.number="minorsCount"
@change="onPaxUpdated()"
v-show="nightsCount > 0"/>
<p class="text-sm italic"
v-show="nightsCount === 0">
Bitte zuerst einen Zeitraum wählen
</p>
</div>
<div v-if="adolescentsAge > 0">
<label class="mb-2 font-bold">
davon Kinder 6-{{ adolescentsAge }} Jahre*
</label>
<input type="number"
class="form-field"
min="0"
v-model.number="adolescentsCount"
@change="onPaxUpdated()"
v-show="nightsCount > 0"/>
<p class="text-sm italic"
v-show="nightsCount === 0">
Bitte zuerst einen Zeitraum wählen
</p>
</div>
<div class="sm:col-span-2 text-sm">
*Die Kosten für Essen und Kurtaxe werden wir entsprechend der Altersstruktur der Gäste in der Rechnung
anpassen.
</div>
</div>
<div class="mb-8"
v-show="options.length > 0 && !submitted">
<div class="mb-2 font-bold">
Optionale Zusatzleistungen
</div>
<ul class="mb-0 p-0 space-y-2">
<li v-for="option of options" :key="option.uid">
<label class="flex items-start">
<input type="checkbox"
class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
v-model="selectedOptions"
:value="option">
<span class="pl-2 leading-none">{{ option.title }}{{ formatOptionPriceType(option) }}</span>
</label>
</li>
</ul>
</div>
<div class="mb-8"
v-show="boards.length > 0 && !submitted">
<label class="mb-2 font-bold">
Verpflegungsleistungen
</label>
<select class="form-field"
v-model="selectedBoard"
v-if="selectedPax >= 30">
<option :value="null" disabled>bitte auswählen</option>
<option v-for="board of boards"
:value="board"
:key="board.uid">
{{ board.title }} ({{ formatCurrency(board.price) }} pro Person und Nacht)
</option>
</select>
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.board"
v-html="formErrors.board"></div>
<div v-show="selectedPax < 30">
<span class="text-red-600">Erst ab 30 Personen buchbar.</span>
</div>
</div>
<div class="mb-8">
<div class="pb-2" v-if="submitted">
<p class="text-lg font-bold pb-2">
Vielen Dank für Deine {{ mode === 'booking' ? 'Buchung' : 'Anfrage' }}.
</p>
<p>
Für folgendes habt ihr euch entschieden:
</p>
</div>
<div class="p-2 uppercase bg-ep-primary-dark text-white w-full">
Preise
</div>
<div class="bg-zinc-100 p-2 mb-2">
<table class="w-full" v-show="nightsCount > 0">
<tbody>
<tr class="odd:bg-zinc-100">
<th class="text-left py-2">Zeitraum</th>
<td>{{ rangeFormatted }}, {{ nightsCount }} Nächte</td>
</tr>
<tr class="odd:bg-zinc-100">
<th class="text-left py-2">Personenzahl</th>
<td>
{{ selectedPax }}<span v-if="childrenCount > 0">, davon Kinder {{ childrenCount }}</span>
</td>
</tr>
<tr class="odd:bg-zinc-100">
<th class="text-left py-2">Basispreis</th>
<td>{{ formatCurrency(pricePax.base) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-if="pricePax.additional.EUR > 0 || pricePax.additional.CHF > 0">
<th class="text-left py-2">Aufpreis Personenzahl</th>
<td>{{ formatCurrency(pricePax.additional) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-if="priceShortTerm.EUR > 0 || priceShortTerm.CHF > 0">
<th class="text-left py-2">Aufpreis Kurzzeit</th>
<td>{{ formatCurrency(priceShortTerm) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-for="selectedOption in selectedOptions">
<th class="text-left py-2">{{ selectedOption.title }}</th>
<td>{{ formatCurrency(calculateOptionPrice(selectedOption)) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-if="priceBoard.EUR > 0 || priceBoard.CHF > 0">
<th class="text-left py-2">{{ selectedBoard.title }}</th>
<td>{{ formatCurrency(priceBoard) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-if="false === isSelfCatering && undersubscriptionLimit > 0">
<th class="text-left py-2">Verpflegungs-Aufschlag für Gruppen unter {{ undersubscriptionLimit }} Personen</th>
<td>{{ formatCurrency(pricePax.undersubscription) }}</td>
</tr>
<tr class="odd:bg-zinc-100"
v-if="priceRunningCosts.EUR > 0 || priceRunningCosts.CHF > 0">
<th class="text-left py-2">Strom- und Abfallgebühren</th>
<td>{{ formatCurrency(priceRunningCosts) }}</td>
</tr>
<tr class="odd:bg-zinc-100">
<th class="text-left py-2">Gesamtpreis</th>
<td>
<strong>{{ formatCurrency(priceTotal) }}</strong>
<br>
<small>{{ taxLabel }}</small>
</td>
</tr>
</tbody>
</table>
<p class="text-sm italic"
v-show="nightsCount === 0">
Bitte zuerst einen Zeitraum wählen
</p>
</div>
<div v-if="mode === 'booking' && submitted">
Bitte beachtet, dass die Buchung bei uns eingegangen, aber <strong>noch nicht bestätigt</strong> ist.
Wir behalten uns vor, die Buchungen auf Verfügbarkeit und Länge des gewünschten Aufenthalts zu prüfen.
Erst wenn wir euch die Buchung per Mail bestätigt haben, wird sie bindend.
</div>
<div v-if="mode === 'inquiry' && submitted">
Die Anfrage ist bei uns eingegangen und wird schnellstmöglich bearbeitet. Wir melden uns telefonisch
oder per Mail bei euch.
</div>
</div>
<div class="mb-8"
v-show="nightsCount > 0 && !submitted">
<div class="p-2 uppercase bg-ep-primary-dark text-white w-full">
Jetzt <span v-if="!forceInquiry">buchen</span><span v-if="forceInquiry">anfragen</span>
</div>
<div class="bg-zinc-100 p-2">
<div class="grid md:grid-cols-2 gap-4 mb-4">
<div :class="{ 'has-error': formErrors.name }">
<label class="font-bold mb-2">
Name*
</label>
<input type="text"
class="form-field"
v-model="name">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.name"
v-html="formErrors.name"></div>
</div>
<div :class="{ 'has-error': formErrors.street }">
<label class="font-bold mb-2">
Strasse, Nr.*
</label>
<input type="text"
class="form-field"
v-model="street">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.street"
v-html="formErrors.street"></div>
</div>
<div :class="{ 'has-error': formErrors.postcode }">
<label class="font-bold mb-2">
Postleitzahl*
</label>
<input type="text"
class="form-field"
v-model="postcode">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.postcode"
v-html="formErrors.postcode"></div>
</div>
<div :class="{ 'has-error': formErrors.city }">
<label class="font-bold mb-2">
Ort*
</label>
<input type="text"
class="form-field"
v-model="city">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.city"
v-html="formErrors.city"></div>
</div>
<div :class="{ 'has-error': formErrors.group }">
<label class="font-bold mb-2">
Name der Gruppe*
</label>
<input type="text"
class="form-field"
v-model="group">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.group"
v-html="formErrors.group"></div>
</div>
<div :class="{ 'has-error': formErrors.email }">
<label class="font-bold mb-2">
E-Mail*
</label>
<input type="text"
class="form-field"
v-model="email">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.email"
v-html="formErrors.email"></div>
</div>
<div :class="{ 'has-error': formErrors.phone }">
<label class="font-bold mb-2">
Telefon*
</label>
<input type="text"
class="form-field"
v-model="phone">
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.phone"
v-html="formErrors.phone"></div>
</div>
<div>
<label class="font-bold mb-2">
Bemerkungen/Wünsche
</label>
<textarea class="form-field"
cols="30"
rows="3"
v-model="remarks"/>
</div>
<div v-if="mode === 'booking'" :class="{ 'has-error': formErrors.confirmation }">
<label for="confirmation" class="flex items-start">
<input type="checkbox" v-model="confirmation" name="confirmation" id="confirmation">
<span class="block ml-2">
Durch Anklicken des Buttons 'Buchung abschicken' bestätige ich, dass ich die
<a :href="termsUrls[countryCode]"
class="text-ep-primary" target="_blank">
Allgemeinen Geschäftsbedingungen (AGB)
</a> gelesen habe und damit einverstanden bin, dass eine
kostenpflichtige Buchung zustande kommt.
</span>
</label>
<div class="text-sm text-red-600 mt-1"
v-show="formErrors.confirmation"
v-html="formErrors.confirmation"></div>
</div>
</div>
<div class="py-4">
Ihr erhaltet von uns eine Bestätigung. Wir behalten uns vor die Buchung zu prüfen. Erst nach
Rückbestätigung von uns gilt die Buchung auch von unserer Seite als bindend.
</div>
<div class="flex items-center space-x-2">
<button class="button bg-button"
type="submit"
v-if="!forceInquiry"
@click.prevent="submitForm('booking')">
Buchung abschicken
</button>
<button class="button bg-button"
:class="{ 'button--small bg-ep-secondary': !forceInquiry }"
type="submit"
@click.prevent="submitForm('inquiry')">
als Anfrage senden
</button>
</div>
</div>
</div>
<div>
<img :src="logoAt" alt="" v-if="countryCode === 'AT'" class="block w-full h-auto max-w-24">
<img :src="logoCh" alt="" v-if="countryCode === 'CH'" class="block w-full h-auto max-w-24">
</div>
<div class="fixed top-0 left-0 inset-0 bg-white/85 flex items-center justify-center" v-show="processing">
<img :src="loaderUri" alt="Loading...">
</div>
</div>
</template>
<script>
import dayjs from 'dayjs'
import 'dayjs/locale/de'
import 'flatpickr';
import {German} from 'flatpickr/dist/l10n/de';
import Vue from 'vue';
export default {
name: 'GroupsPriceCalculator',
props: {
loaderUri: {
type: String,
required: true
},
endpoint: {
type: String,
required: true
},
logoAt: {
type: String,
},
logoCh: {
type: String,
},
configsJson: {
type: String,
required: true
},
boardsJson: {
type: String,
required: true
},
optionsJson: {
type: String,
required: true
},
runningCostsFactorEur: {
type: Number,
default: 2.5,
},
runningCostsFactorChf: {
type: Number,
default: 2.9,
},
undersubscription30Eur: {
type: Number,
default: 5.0,
},
undersubscription30Chf: {
type: Number,
default: 5.0,
},
undersubscription40Eur: {
type: Number,
default: 2.5,
},
undersubscription40Chf: {
type: Number,
default: 2.5,
},
countryCode: {
type: String,
required: true
},
shortTermFactors: {
type: Array,
default() {
return [
{ nights: 1, factor: 0.3 },
{ nights: 2, factor: 0.2 },
{ nights: 3, factor: 0.1 },
]
}
},
mainSeasonFrom: {
type: String,
default: null,
},
mainSeasonTo: {
type: String,
default: null,
},
minBookingDays: {
type: Number,
default: 5,
},
taxLabel: {
type: String,
default: 'zzgl. vorab zu entrichtender Ortstaxe',
},
adolescentsAge: {
type: Number,
default: 0,
}
},
data() {
return {
configs: JSON.parse(this.configsJson),
boards: JSON.parse(this.boardsJson),
options: JSON.parse(this.optionsJson),
processing: false,
submitted: false,
picker: null,
formErrors: {},
selectedFrom: null,
selectedTo: null,
selectedRange: [],
selectedPax: 30,
childrenCount: 0,
minorsCount: 0,
adolescentsCount: 0,
selectedOptions: [],
selectedBoard: null,
name: '',
email: '',
phone: '',
remarks: '',
group: '',
street: '',
postcode: '',
city: '',
mode: 'booking',
confirmation: false,
termsUrls: {
AT: 'https://www.ep-reisen.de/fileadmin/user_upload/allgemein/AGB_Gruppen/AGB_Gruppen_CLLT_Touristik_GmbH.pdf',
CH: 'https://www.ep-reisen.de/fileadmin/user_upload/allgemein/AGB_Gruppen/AVB_Gruppen_AlpineVacation_GmbH.pdf',
IT: 'https://www.ep-reisen.de/fileadmin/user_upload/allgemein/AGB_Gruppen/AGB_Gruppen_E_P_Reisen.pdf',
}
}
},
methods: {
formatCurrency({ EUR, CHF }) {
let price
if ('CH' === this.countryCode) {
let formatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'CHF',
})
price = formatter.format(CHF)
} else {
let formatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
})
price = formatter.format(EUR)
}
return price
},
calculateOptionPrice(option) {
let price = {
EUR: 0,
CHF: 0,
};
let optionPriceEUR = option.price.EUR
let optionPriceCHF = option.price.CHF
if (1 === option.type) {
price.EUR = optionPriceEUR;
price.CHF = optionPriceCHF;
} else if (2 === option.type) {
price.EUR = optionPriceEUR * this.paxCount;
price.CHF = optionPriceCHF * this.paxCount;
} else if (3 === option.type) {
price.EUR = optionPriceEUR * this.nightsCount;
price.CHF = optionPriceCHF * this.nightsCount;
} else if (4 === option.type) {
price.EUR = optionPriceEUR * this.nightsCount * this.paxCount;
price.CHF = optionPriceCHF * this.nightsCount * this.paxCount;
}
return price;
},
formatOptionPriceType(option) {
if (2 === option.type) {
return ` (${this.formatCurrency(option.price)} pro Person)`
}
if (3 === option.type) {
return ` (${this.formatCurrency(option.price)} pro Nacht)`
}
if (4 === option.type) {
return ` (${this.formatCurrency(option.price)} pro Person und Nacht)`
}
return ` (${this.formatCurrency(option.price)})`
},
initSelectableRanges() {
let enabledDates = [];
for (let config of this.configs) {
const dateFrom = dayjs(config.dateFrom);
const dateTo = dayjs(config.dateTo);
enabledDates.push({
from: dateFrom.format('DD.MM.YYYY'),
to: dateTo.format('DD.MM.YYYY')
});
}
this.picker.set('enable', enabledDates);
if (enabledDates.length > 0) {
const firstDate = enabledDates[0].from;
this.picker.set('minDate', firstDate);
}
},
updateSelectedRange() {
let range = [];
let currentDate = this.selectedFrom;
while (currentDate < this.selectedTo) {
range.push(currentDate);
currentDate = currentDate.add(1, 'day');
}
this.selectedRange = range;
},
onPaxUpdated() {
if (this.selectedPax < 30) {
this.selectedBoard = null;
}
},
submitForm(mode) {
this.mode = mode
let confirmation = 'inquiry' === mode ? true : this.confirmation
let formData = new URLSearchParams({
'tx_epproducts_ajax[groupsPriceInquiry][mode]': mode,
'tx_epproducts_ajax[groupsPriceInquiry][confirmation]': confirmation ? '1' : '0',
'tx_epproducts_ajax[groupsPriceInquiry][name]': this.name ? this.name : '',
'tx_epproducts_ajax[groupsPriceInquiry][group]': this.group ? this.group : '',
'tx_epproducts_ajax[groupsPriceInquiry][street]': this.street ? this.street : '',
'tx_epproducts_ajax[groupsPriceInquiry][postcode]': this.postcode ? this.postcode : '',
'tx_epproducts_ajax[groupsPriceInquiry][city]': this.city ? this.city : '',
'tx_epproducts_ajax[groupsPriceInquiry][email]': this.email? this.email : '',
'tx_epproducts_ajax[groupsPriceInquiry][phone]': this.phone ? this.phone : '',
'tx_epproducts_ajax[groupsPriceInquiry][remarks]': this.remarks,
'tx_epproducts_ajax[groupsPriceInquiry][dateFrom]': this.selectedFrom.format('DD.MM.YYYY'),
'tx_epproducts_ajax[groupsPriceInquiry][dateTo]': this.selectedTo.format('DD.MM.YYYY'),
'tx_epproducts_ajax[groupsPriceInquiry][nights]': this.nightsCount,
'tx_epproducts_ajax[groupsPriceInquiry][pax]': this.selectedPax,
'tx_epproducts_ajax[groupsPriceInquiry][children]': this.childrenCount,
'tx_epproducts_ajax[groupsPriceInquiry][minors]': this.minorsCount,
'tx_epproducts_ajax[groupsPriceInquiry][adolescents]': this.adolescentsCount,
'tx_epproducts_ajax[groupsPriceInquiry][summary][paxBase]': this.formatCurrency(this.pricePax.base),
'tx_epproducts_ajax[groupsPriceInquiry][summary][paxAdditional]': this.formatCurrency(this.pricePax.additional),
'tx_epproducts_ajax[groupsPriceInquiry][summary][undersubscription]': this.formatCurrency(this.pricePax.undersubscription),
'tx_epproducts_ajax[groupsPriceInquiry][summary][shortTerm]': this.formatCurrency(this.priceShortTerm),
'tx_epproducts_ajax[groupsPriceInquiry][summary][options]': this.formatCurrency(this.priceOptions),
'tx_epproducts_ajax[groupsPriceInquiry][summary][board]': this.formatCurrency(this.priceBoard),
'tx_epproducts_ajax[groupsPriceInquiry][summary][runningCosts]': this.formatCurrency(this.priceRunningCosts),
'tx_epproducts_ajax[groupsPriceInquiry][summary][total]': this.formatCurrency(this.priceTotal),
})
if (this.selectedBoard) {
formData.append('tx_epproducts_ajax[groupsPriceInquiry][board]', this.selectedBoard.uid)
}
this.selectedOptions.map(option => (
formData.append('tx_epproducts_ajax[groupsPriceInquiry][options][]', option.uid)
))
this.processing = true;
this.submitted = false;
fetch(this.endpoint, { method: 'POST', body: formData })
.then(response => response.json())
.then((json) => {
if ('validation' === json.status) {
this.formErrors = json.errors
} else {
this.submitted = true;
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'Preisberechnung'
})
}
})
.finally(() => {
this.processing = false
})
},
},
computed: {
isSelfCatering() {
let selectedBoard = this.selectedBoard
if (null === selectedBoard) {
return true
}
if ('CH' === this.countryCode) {
return selectedBoard.price.CHF === 0
} else {
return selectedBoard.price.EUR === 0
}
},
undersubscriptionLimit() {
if (this.paxCount < 40) {
return 40
} else if (this.paxCount < 50) {
return 50
}
return 0
},
paxCount() {
// subtract number of children from pax for price calculation, but minimum 30 pax
return Math.max(this.selectedPax - this.childrenCount, 30)
},
nightsCount() {
return this.selectedRange.length;
},
forceInquiry() {
return 1 === this.selectedRange.length
},
rangeFormatted() {
if (null === this.selectedFrom || null === this.selectedTo) {
return '-';
}
return this.selectedFrom.format('DD.MM.YYYY') + ' - ' + this.selectedTo.format('DD.MM.YYYY');
},
pricePax() {
let base = {
EUR: 0,
CHF: 0,
}
let additional = {
EUR: 0,
CHF: 0,
}
let undersubscription = {
EUR: 0,
CHF: 0,
}
for (let date of this.selectedRange) {
for (let config of this.configs) {
if (date >= dayjs(config.dateFrom) && date < dayjs(config.dateTo)) {
base.EUR += config.price.EUR
base.CHF += config.price.CHF
let included = config.personsIncluded
if (this.paxCount > included) {
let additionalPax = this.paxCount - included
additional.EUR += additionalPax * config.priceAdditionalPerson.EUR
additional.CHF += additionalPax * config.priceAdditionalPerson.CHF
}
}
}
}
if (false === this.isSelfCatering) {
if (this.paxCount < 40) {
undersubscription.EUR = this.paxCount * this.undersubscription30Eur * this.selectedRange.length
undersubscription.CHF = this.paxCount * this.undersubscription30Chf * this.selectedRange.length
} else if (this.paxCount < 50) {
undersubscription.EUR = this.paxCount * this.undersubscription40Eur * this.selectedRange.length
undersubscription.CHF = this.paxCount * this.undersubscription40Chf * this.selectedRange.length
}
}
return {base, additional, undersubscription}
},
priceOptions() {
let price = {
EUR: 0,
CHF: 0,
};
for (let option of this.selectedOptions) {
let optionPrice = this.calculateOptionPrice(option)
price.EUR += optionPrice.EUR;
price.CHF += optionPrice.CHF;
}
return price;
},
priceBoard() {
let price = {
EUR: 0,
CHF: 0,
}
if (this.selectedBoard) {
price.EUR = this.selectedBoard.price.EUR * this.nightsCount * this.paxCount;
price.CHF = this.selectedBoard.price.CHF * this.nightsCount * this.paxCount;
}
return price
},
priceShortTerm() {
let price = {
EUR: 0,
CHF: 0,
}
const totalPricePax = this.pricePax.base.EUR + this.pricePax.additional.EUR;
const totalPricePaxCHF = this.pricePax.base.CHF + this.pricePax.additional.CHF;
const shorTermFactor = this.shortTermFactors.find(element => element.nights === this.nightsCount);
if (undefined !== shorTermFactor) {
price.EUR = totalPricePax * shorTermFactor.factor;
price.CHF = totalPricePaxCHF * shorTermFactor.factor;
}
return price
},
priceRunningCosts() {
return {
EUR: this.nightsCount * this.selectedPax * this.runningCostsFactorEur,
CHF: this.nightsCount * this.selectedPax * this.runningCostsFactorChf,
}
},
priceTotal() {
let totalEUR = this.pricePax.base.EUR
+ this.pricePax.additional.EUR
+ this.pricePax.undersubscription.EUR
+ this.priceOptions.EUR
+ this.priceBoard.EUR
+ this.priceShortTerm.EUR
+ this.priceRunningCosts.EUR
let totalCHF = 0
// calculate CHF price when pax price in CHF is available since CHF prices may not be provided via CMS yet
if (0 < this.pricePax.base.CHF) {
totalCHF = this.pricePax.base.CHF
+ this.pricePax.additional.CHF
+ this.pricePax.undersubscription.CHF
+ this.priceOptions.CHF
+ this.priceBoard.CHF
+ this.priceShortTerm.CHF
+ this.priceRunningCosts.CHF
}
return {
EUR: totalEUR,
CHF: totalCHF,
}
}
},
mounted() {
Vue.nextTick(() => {
this.picker = flatpickr(this.$refs.picker, {
mode: 'range',
dateFormat: 'd.m.Y',
locale: German,
onChange: selectedDates => {
if (2 === selectedDates.length) {
this.selectedFrom = dayjs(selectedDates[0]);
this.selectedTo = dayjs(selectedDates[1]);
this.updateSelectedRange();
}
}
});
this.initSelectableRanges();
});
}
}
</script>
@@ -9,6 +9,9 @@ export default class extends Controller {
zoom: Number, zoom: Number,
externalLink: String, externalLink: String,
markers: Array, markers: Array,
tileUrl: String,
apiKey: String,
attribution: String,
} }
connect() { connect() {
@@ -44,9 +47,21 @@ export default class extends Controller {
} }
this.map.fitBounds(bounds) this.map.fitBounds(bounds)
} }
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer(this.tileUrl(), {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' // The site sends Referrer-Policy: same-origin, which strips the Referer on
// cross-origin requests. The tile provider needs it to match its key against
// the allowed-domain list, so widen the policy for tile requests only.
referrerPolicy: 'strict-origin-when-cross-origin',
attribution: this.attributionValue
}).addTo(this.map) }).addTo(this.map)
} }
tileUrl() {
if ('' === this.tileUrlValue || '' === this.apiKeyValue) {
return ''
}
const separator = -1 === this.tileUrlValue.indexOf('?') ? '?' : '&'
return this.tileUrlValue + separator + 'apiKey=' + encodeURIComponent(this.apiKeyValue)
}
} }
@@ -1,10 +0,0 @@
// Initialize vue app
import Vue from 'vue'
import vueCustomElement from 'vue-custom-element'
import 'document-register-element/build/document-register-element'
import GroupsPriceCalculator from './components/GroupsPriceCalculator'
Vue.use(vueCustomElement)
Vue.config.productionTip = false
Vue.customElement('groups-price-calculator', GroupsPriceCalculator)
@@ -3,7 +3,3 @@
@apply block w-full h-auto; @apply block w-full h-auto;
} }
img.lazyloading {
background: url(../../images/lazy.png) no-repeat top center;
}
@@ -18,12 +18,12 @@
</f:if> </f:if>
<f:if condition="{image}"> <f:if condition="{image}">
<f:then> <f:then>
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{city.name}" /> alt="{city.name}" />
</f:then> </f:then>
<f:else> <f:else>
<f:image src="EXT:ep_theme/Resources/Public/images/lazy.png" <f:image src="EXT:ep_theme/Resources/Public/images/lazy.png"
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:section name="Teaser"> <f:section name="Teaser">
@@ -18,12 +17,12 @@
</f:if> </f:if>
<f:if condition="{image}"> <f:if condition="{image}">
<f:then> <f:then>
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{concept.name}" /> alt="{concept.name}" />
</f:then> </f:then>
<f:else> <f:else>
<f:image src="EXT:ep_theme/Resources/Public/images/lazy.png" <f:image src="EXT:ep_theme/Resources/Public/images/lazy.png"
@@ -13,12 +13,12 @@
<div class="col-span-2 md:col-span-1"> <div class="col-span-2 md:col-span-1">
<div class="relative max-w-md mx-auto mb-8"> <div class="relative max-w-md mx-auto mb-8">
<f:if condition="{image}"> <f:if condition="{image}">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
height="400c" height="400c"
alt="{image.alternative}" /> alt="{image.alternative}" />
</f:if> </f:if>
<a href="tel:{settings.phoneNumber -> v:format.pregReplace(pattern: '/[\-\s]/', replacement: '')}" <a href="tel:{settings.phoneNumber -> v:format.pregReplace(pattern: '/[\-\s]/', replacement: '')}"
rel="nofollow" rel="nofollow"
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:alias map="{button: data.flexForm.buttons.0}"> <f:alias map="{button: data.flexForm.buttons.0}">
@@ -11,11 +10,11 @@
<div class="col-md-6"> <div class="col-md-6">
<a href="{f:uri.typolink(parameter: button.container.link)}" title="{button.container.label -> f:format.raw()}"> <a href="{f:uri.typolink(parameter: button.container.link)}" title="{button.container.label -> f:format.raw()}">
<f:if condition="{image}"> <f:if condition="{image}">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
alt="{image.alternative}" /> alt="{image.alternative}" />
</f:if> </f:if>
</a> </a>
</div> </div>
@@ -11,12 +11,12 @@
<div class="md:flex md:items-start py-4"> <div class="md:flex md:items-start py-4">
<div class="px-16 pb-4 md:px-4 md:pb-0 md:w-1/6"> <div class="px-16 pb-4 md:px-4 md:pb-0 md:w-1/6">
<f:if condition="{image}"> <f:if condition="{image}">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
height="400c" height="400c"
alt="{image.alternative}" /> alt="{image.alternative}" />
</f:if> </f:if>
</div> </div>
<div class="px-16 md:px-4 md:w-5/6 md:pl-8 text-center text-white md:text-left" data-rte-content> <div class="px-16 md:px-4 md:w-5/6 md:pl-8 text-center text-white md:text-left" data-rte-content>
@@ -14,12 +14,12 @@
<div class="col-span-2 md:col-span-1"> <div class="col-span-2 md:col-span-1">
<div class="relative max-w-md mx-auto mb-8"> <div class="relative max-w-md mx-auto mb-8">
<f:if condition="{teaserImage}"> <f:if condition="{teaserImage}">
<ep:lazyLoadImage image="{teaserImage}" <f:image image="{teaserImage}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
height="400c" height="400c"
alt="{teaserImage.alternative}" /> alt="{teaserImage.alternative}" />
</f:if> </f:if>
<a href="tel:{settings.phoneNumber -> v:format.pregReplace(pattern: '/[\-\s]/', replacement: '')}" <a href="tel:{settings.phoneNumber -> v:format.pregReplace(pattern: '/[\-\s]/', replacement: '')}"
rel="nofollow" rel="nofollow"
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:alias map="{button: data.flexForm.buttons.0}"> <f:alias map="{button: data.flexForm.buttons.0}">
@@ -11,11 +10,11 @@
<div class="col-md-6"> <div class="col-md-6">
<a href="{f:uri.typolink(parameter: button.container.link)}" title="{button.container.label -> f:format.raw()}"> <a href="{f:uri.typolink(parameter: button.container.link)}" title="{button.container.label -> f:format.raw()}">
<f:if condition="{teaserImage}"> <f:if condition="{teaserImage}">
<ep:lazyLoadImage image="{teaserImage}" <f:image image="{teaserImage}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
alt="{teaserImage.alternative}" /> alt="{teaserImage.alternative}" />
</f:if> </f:if>
</a> </a>
</div> </div>
@@ -11,12 +11,12 @@
<div class="md:flex md:items-start py-4"> <div class="md:flex md:items-start py-4">
<div class="px-16 pb-4 md:px-4 md:pb-0 md:w-1/6"> <div class="px-16 pb-4 md:px-4 md:pb-0 md:w-1/6">
<f:if condition="{teaserImage}"> <f:if condition="{teaserImage}">
<ep:lazyLoadImage image="{teaserImage}" <f:image image="{teaserImage}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
height="400c" height="400c"
alt="{teaserImage.alternative}" /> alt="{teaserImage.alternative}" />
</f:if> </f:if>
</div> </div>
<div class="px-16 md:px-4 md:w-5/6 md:pl-8 text-center text-white md:text-left" data-rte-content> <div class="px-16 md:px-4 md:w-5/6 md:pl-8 text-center text-white md:text-left" data-rte-content>
@@ -158,11 +158,11 @@
target="_blank" target="_blank"
rel="nofollow" rel="nofollow"
title="Global Compact"> title="Global Compact">
<ep:lazyLoadImage src="EXT:ep_theme/Resources/Public/images/global_compact.png" <f:image src="EXT:ep_theme/Resources/Public/images/global_compact.png"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="100" width="100"
alt="Global Compact" /> alt="Global Compact" />
</a> </a>
</div> </div>
</div> </div>
@@ -184,8 +184,10 @@
<f:format.raw>{totalRatings.jsonData->f:format.json()}</f:format.raw> <f:format.raw>{totalRatings.jsonData->f:format.json()}</f:format.raw>
</script> </script>
<f:if condition="{site.identifier} == 'ep-reisen'"> <f:comment><!--
<f:render partial="Chatbot" arguments="{_all}"/> <f:if condition="{site.identifier} == 'ep-reisen'">
</f:if> <f:render partial="Chatbot" arguments="{_all}"/>
</f:if>
--></f:comment>
</html> </html>
@@ -9,12 +9,12 @@
{ep:icon(icon: 'flag-{hotel.country.code -> f:format.case(mode: \'lower\')}', class: 'w-8 h-6 absolute top-0 left-0')} {ep:icon(icon: 'flag-{hotel.country.code -> f:format.case(mode: \'lower\')}', class: 'w-8 h-6 absolute top-0 left-0')}
<f:if condition="{hotel.images.original.0}"> <f:if condition="{hotel.images.original.0}">
<f:then> <f:then>
<ep:lazyLoadImage image="{hotel.images.original.0}" <f:image image="{hotel.images.original.0}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{hotel.name}" /> alt="{hotel.name}" />
</f:then> </f:then>
<f:else> <f:else>
<f:image src="EXT:ep_theme/Resources/Public/images/lazy.png" <f:image src="EXT:ep_theme/Resources/Public/images/lazy.png"
@@ -15,12 +15,12 @@
<a href="{f:uri.image(image: image, cropVariant: 'zoom', maxWidth: '2560')}" <a href="{f:uri.image(image: image, cropVariant: 'zoom', maxWidth: '2560')}"
title="{image.title}" title="{image.title}"
data-action="lightbox#open"> data-action="lightbox#open">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="770" width="770"
height="430c+100" height="430c+100"
alt="{image.alternative}"/> alt="{image.alternative}"/>
</a> </a>
</f:alias> </f:alias>
<f:if condition="{thumbnails}"> <f:if condition="{thumbnails}">
@@ -35,12 +35,12 @@
data-lightbox-caption="{image.originalResource.description}" data-lightbox-caption="{image.originalResource.description}"
title="{image.title}" title="{image.title}"
class="block"> class="block">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="150" width="150"
height="85c+100" height="85c+100"
alt="{image.alternative}"/> alt="{image.alternative}"/>
</a> </a>
</f:if> </f:if>
</f:for> </f:for>
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="odd:bg-zinc-50 p-4" data-rte-content> <div class="odd:bg-zinc-50 p-4" data-rte-content>
@@ -18,12 +17,12 @@
</h3> </h3>
{journey.byCar -> f:format.html()} {journey.byCar -> f:format.html()}
<f:if condition="{journey.image}"> <f:if condition="{journey.image}">
<ep:lazyLoadImage image="{journey.image}" <f:image image="{journey.image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="770" width="770"
height="430c+100" height="430c+100"
alt="{journey.image.alternative}" /> alt="{journey.image.alternative}" />
</f:if> </f:if>
</f:if> </f:if>
</div> </div>
@@ -0,0 +1,10 @@
<div class="w-full aspect-w-16 aspect-h-9 {class}"
data-controller="osm"
data-osm-tile-url-value="{settings.mapTileUrl}"
data-osm-api-key-value="{settings.mapApiKey}"
data-osm-attribution-value="{settings.mapAttribution}"
<f:if condition="{zoom}">data-osm-zoom-value="{zoom}"</f:if>
<f:if condition="{lat}">data-osm-lat-value="{lat}" data-osm-lng-value="{lng}"</f:if>
<f:if condition="{externalLink}">data-osm-external-link-value="{externalLink}"</f:if>
<f:if condition="{markers}">data-osm-markers-value='{markers -> f:format.raw()}'</f:if>
></div>
@@ -10,8 +10,9 @@
<f:then> <f:then>
<f:if condition="{newsItem.mediaPreviews.0}"> <f:if condition="{newsItem.mediaPreviews.0}">
<f:alias map="{mediaElement: '{newsItem.mediaPreviews.0}'}"> <f:alias map="{mediaElement: '{newsItem.mediaPreviews.0}'}">
<img class="block w-full h-auto lazyload" <img class="block w-full h-auto"
data-src="{f:uri.image(image: mediaElement, width: '640', height: '360c+50')}" src="{f:uri.image(image: mediaElement, width: '640', height: '360c+50')}"
loading="lazy"
alt="{mediaElement.originalResource.alternative}"> alt="{mediaElement.originalResource.alternative}">
</f:alias> </f:alias>
</f:if> </f:if>
@@ -19,8 +20,9 @@
<f:else> <f:else>
<f:if condition="{newsItem.media}"> <f:if condition="{newsItem.media}">
<f:alias map="{mediaElement: '{newsItem.media.0}'}"> <f:alias map="{mediaElement: '{newsItem.media.0}'}">
<img class="block w-full h-auto lazyload" <img class="block w-full h-auto"
data-src="{f:uri.image(image: mediaElement, width: '640', height: '360c+50')}" src="{f:uri.image(image: mediaElement, width: '640', height: '360c+50')}"
loading="lazy"
alt="{mediaElement.originalResource.alternative}"> alt="{mediaElement.originalResource.alternative}">
</f:alias> </f:alias>
</f:if> </f:if>
@@ -8,11 +8,11 @@
</div> </div>
<div class="col-span-1"> <div class="col-span-1">
<f:if condition="{officetip.author.image}"> <f:if condition="{officetip.author.image}">
<ep:lazyLoadImage image="{officetip.author.image}" <f:image image="{officetip.author.image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="120" width="120"
alt="{officetip.author.name}" /> alt="{officetip.author.name}" />
</f:if> </f:if>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
@@ -16,19 +16,19 @@
<div class="relative{f:if(condition: landscape, then: ' sm:w-2/5')}"> <div class="relative{f:if(condition: landscape, then: ' sm:w-2/5')}">
<f:if condition="{productImage}"> <f:if condition="{productImage}">
<f:then> <f:then>
<ep:lazyLoadImage image="{productImage}" <f:image image="{productImage}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{product.name}" /> alt="{product.name}" />
</f:then> </f:then>
<f:else> <f:else>
<f:image src="EXT:ep_theme/Resources/Public/images/lazy.png" <f:image src="EXT:ep_theme/Resources/Public/images/lazy.png"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{product.name}" /> alt="{product.name}" />
</f:else> </f:else>
</f:if> </f:if>
<div class="absolute top-0 left-0 z-20 inset-x-0 p-2 flex justify-between items-center {f:if(condition: concept, then: 'bg-concept-{concept.code}/70', else: 'bg-ep-primary/80')}"> <div class="absolute top-0 left-0 z-20 inset-x-0 p-2 flex justify-between items-center {f:if(condition: concept, then: 'bg-concept-{concept.code}/70', else: 'bg-ep-primary/80')}">
@@ -18,12 +18,12 @@
</f:if> </f:if>
<f:if condition="{image}"> <f:if condition="{image}">
<f:then> <f:then>
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{region.name}" /> alt="{region.name}" />
</f:then> </f:then>
<f:else> <f:else>
<f:image src="EXT:ep_theme/Resources/Public/images/lazy.png" <f:image src="EXT:ep_theme/Resources/Public/images/lazy.png"
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:if condition="{region.webcam.available}"> <f:if condition="{region.webcam.available}">
@@ -13,11 +12,11 @@
data-action="lightbox#open" data-action="lightbox#open"
data-lightbox-caption="Webcam {region.name}" data-lightbox-caption="Webcam {region.name}"
title="Webcam {region.name}"> title="Webcam {region.name}">
<ep:lazyLoadImage src="{region.webcam.url}" <f:image src="{region.webcam.url}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="330" width="330"
alt="Webcam {region.name}" /> alt="Webcam {region.name}" />
</a> </a>
</div> </div>
</f:if> </f:if>
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:if condition="{region.regionMaps}"> <f:if condition="{region.regionMaps}">
@@ -17,11 +16,11 @@
data-lightbox-index="{iteration.cycle}" data-lightbox-index="{iteration.cycle}"
data-lightbox-caption="Pistenplan {region.name}" data-lightbox-caption="Pistenplan {region.name}"
title="Pistenplan {region.name}"> title="Pistenplan {region.name}">
<ep:lazyLoadImage image="{regionMap}" <f:image image="{regionMap}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="330" width="330"
alt="{regionMap.alternative}"/> alt="{regionMap.alternative}"/>
</a> </a>
</f:for> </f:for>
</div> </div>
@@ -274,8 +274,12 @@
2: 'DZ / 2er', 2: 'DZ / 2er',
3: '3er', 3: '3er',
4: '4er', 4: '4er',
5: 'ab 5 Pers.', 5: '5er',
6: 'mit anderen' 6: '6er',
7: '7er',
8: '8er',
9: '9er und mehr',
10: 'mit anderen'
}"/> }"/>
<div class="py-2" <div class="py-2"
data-controller="collapse" data-controller="collapse"
@@ -291,17 +295,19 @@
<div data-collapse-target="container"> <div data-collapse-target="container">
<ul class="mb-0 p-0 pt-4 space-y-2"> <ul class="mb-0 p-0 pt-4 space-y-2">
<f:for each="{filterOptions.roomTypes}" as="type"> <f:for each="{filterOptions.roomTypes}" as="type">
<li> <f:if condition="{roomTypes.{type}}">
<label class="flex items-start"> <li>
<f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary" <label class="flex items-start">
property="roomTypes" <f:form.checkbox class="w-5 h-5 border-ep-primary-light ring-0 bg-transparent focus:ring-0 focus:border-none text-ep-primary"
data="{action: '{stimulusController}#filter'}" property="roomTypes"
value="{type}"/> data="{action: '{stimulusController}#filter'}"
<span class="leading-none pl-2"> value="{type}"/>
{roomTypes.{type}} <span class="leading-none pl-2">
</span> {roomTypes.{type}}
</label> </span>
</li> </label>
</li>
</f:if>
</f:for> </f:for>
</ul> </ul>
</div> </div>
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers" xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
@@ -11,11 +10,11 @@
Teamer vor Ort Teamer vor Ort
</p> </p>
<f:if condition="{teamerInCharge.image}"> <f:if condition="{teamerInCharge.image}">
<ep:lazyLoadImage image="{teamerInCharge.image}" <f:image image="{teamerInCharge.image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto mb-4" class="block w-full h-auto mb-4"
width="330" width="330"
alt="{teamerInCharge.name}"/> alt="{teamerInCharge.name}"/>
</f:if> </f:if>
<p>{teamerInCharge.name}</p> <p>{teamerInCharge.name}</p>
</div> </div>
@@ -9,12 +9,12 @@
<f:if condition="{context.country}"> <f:if condition="{context.country}">
{ep:icon(icon: 'flag-{context.country.code -> f:format.case(mode: \'lower\')}', class: 'w-8 h-6 absolute top-0 left-0')} {ep:icon(icon: 'flag-{context.country.code -> f:format.case(mode: \'lower\')}', class: 'w-8 h-6 absolute top-0 left-0')}
</f:if> </f:if>
<ep:lazyLoadImage image="{teaserImage}" <f:image image="{teaserImage}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{teaserImage.alternative}" /> alt="{teaserImage.alternative}" />
</div> </div>
<div class="flex-1 flex flex-col pt-4 bg-white border border-t-0 border-zinc-200"> <div class="flex-1 flex flex-col pt-4 bg-white border border-t-0 border-zinc-200">
<a href="{f:uri.typolink(parameter: data.header_link)}" class="px-2 peer headline--3 mb-2 no-underline"> <a href="{f:uri.typolink(parameter: data.header_link)}" class="px-2 peer headline--3 mb-2 no-underline">
@@ -1,62 +1,61 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:if condition="{image.uid}"> <f:if condition="{image.uid}">
<f:then> <f:then>
<picture> <picture>
<source media="(min-width: 1201px)" data-srcset="{f:uri.image( <source media="(min-width: 1201px)" srcset="{f:uri.image(
image: image, width: '370', height: '220c-100' image: image, width: '370', height: '220c-100'
)} 370w"/> )} 370w"/>
<source media="(max-width: 1200px)" data-srcset="{f:uri.image( <source media="(max-width: 1200px)" srcset="{f:uri.image(
image: image, width: '320', height: '160c-100' image: image, width: '320', height: '160c-100'
)} 320w"/> )} 320w"/>
<source media="(max-width: 991px)" data-srcset="{f:uri.image( <source media="(max-width: 991px)" srcset="{f:uri.image(
image: image, width: '360', height: '200c-100' image: image, width: '360', height: '200c-100'
)} 360w"/> )} 360w"/>
<source media="(max-width: 768px)" data-srcset="{f:uri.image( <source media="(max-width: 768px)" srcset="{f:uri.image(
image: image, width: '500', height: '250c-100' image: image, width: '500', height: '250c-100'
)} 500w"/> )} 500w"/>
<source media="(max-width: 512px)" data-srcset="{f:uri.image( <source media="(max-width: 512px)" srcset="{f:uri.image(
image: image, width: '350', height: '175c-100' image: image, width: '350', height: '175c-100'
)} 350w"/> )} 350w"/>
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="500" width="500"
height="250c-100" height="250c-100"
alt="{image.alternative}" /> alt="{image.alternative}" />
</picture> </picture>
</f:then> </f:then>
<f:else> <f:else>
<picture> <picture>
<source media="(min-width: 1201px)" data-srcset="{f:uri.image( <source media="(min-width: 1201px)" srcset="{f:uri.image(
src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg', src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg',
width: '370', height: '220c-100' width: '370', height: '220c-100'
)} 370w"/> )} 370w"/>
<source media="(max-width: 1200px)" data-srcset="{f:uri.image( <source media="(max-width: 1200px)" srcset="{f:uri.image(
src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg', src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg',
width: '320', height: '160c-100' width: '320', height: '160c-100'
)} 420w"/> )} 320w"/>
<source media="(max-width: 991px)" data-srcset="{f:uri.image( <source media="(max-width: 991px)" srcset="{f:uri.image(
src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg', src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg',
width: '360', height: '200c-100' width: '360', height: '200c-100'
)} 360w"/> )} 360w"/>
<source media="(max-width: 768px)" data-srcset="{f:uri.image( <source media="(max-width: 768px)" srcset="{f:uri.image(
src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg', src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg',
width: '500', height: '250c-100' width: '500', height: '250c-100'
)} 500w"/> )} 500w"/>
<source media="(max-width: 512px)" data-srcset="{f:uri.image( <source media="(max-width: 512px)" srcset="{f:uri.image(
src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg', src: 'EXT:ep_theme/Resources/Public/images/header-winter.jpg',
width: '350', height: '175c-100' width: '350', height: '175c-100'
)} 320w"/> )} 350w"/>
<ep:lazyLoadImage src="EXT:ep_theme/Resources/Public/images/header-winter.jpg" <f:image src="EXT:ep_theme/Resources/Public/images/header-winter.jpg"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="360" width="360"
height="270c-100" height="270c-100"
alt="" /> alt="" />
</picture> </picture>
</f:else> </f:else>
</f:if> </f:if>
@@ -1,25 +1,27 @@
<f:if condition="{image}"> <f:if condition="{image}">
<f:then> <f:then>
<picture class="product-teaser__image"> <picture class="product-teaser__image">
<source media="(min-width: 1201px)" data-srcset="{f:uri.image( <source media="(min-width: 1201px)" srcset="{f:uri.image(
image: image, width: '320', height: '200c-100' image: image, width: '320', height: '200c-100'
)} 320w"/> )} 320w"/>
<source media="(max-width: 1200px)" data-srcset="{f:uri.image( <source media="(max-width: 1200px)" srcset="{f:uri.image(
image: image, width: '220', height: '130c-100' image: image, width: '220', height: '130c-100'
)} 220w"/> )} 220w"/>
<source media="(max-width: 991px)" data-srcset="{f:uri.image( <source media="(max-width: 991px)" srcset="{f:uri.image(
image: image, width: '260', height: '220c-100' image: image, width: '260', height: '220c-100'
)} 260w"/> )} 260w"/>
<source media="(max-width: 768px)" data-srcset="{f:uri.image( <source media="(max-width: 768px)" srcset="{f:uri.image(
image: image, width: '320', height: '140c-100' image: image, width: '320', height: '140c-100'
)} 320w"/> )} 320w"/>
<img class="scale lazyload" alt="{image.alternative}" <img class="scale" alt="{image.alternative}"
src="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/lazy.png', width: '260', height: '160')}" src="{f:uri.image(image: image, width: '260', height: '160c-100')}"
data-src="{f:uri.image(image: image, width: '260', height: '160c-100')}" loading="lazy"
> >
</picture> </picture>
</f:then> </f:then>
<f:else> <f:else>
<img class="scale" data-src="https://placehold.it/400x300" alt=""> <img class="scale" alt=""
src="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/lazy.png', width: '260', height: '160')}"
loading="lazy">
</f:else> </f:else>
</f:if> </f:if>
@@ -7,8 +7,9 @@
{data.bodytext -> f:format.html()} {data.bodytext -> f:format.html()}
<f:if condition="{files}"> <f:if condition="{files}">
<f:link.typolink parameter="{files.0.link}" title="{files.0.originalResource.title}"> <f:link.typolink parameter="{files.0.link}" title="{files.0.originalResource.title}">
<img class="img-responsive lazyload" <img class="img-responsive"
data-src="{f:uri.image(image: files.0, maxWidth: '770')}" src="{f:uri.image(image: files.0, maxWidth: '770')}"
loading="lazy"
alt="{files.0.originalResource.alternative}"> alt="{files.0.originalResource.alternative}">
</f:link.typolink> </f:link.typolink>
</f:if> </f:if>
@@ -1,7 +1,6 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers" xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:section name="Content"> <f:section name="Content">
@@ -25,12 +24,12 @@
<f:section name="Image"> <f:section name="Image">
<div class="{cssClasses}"> <div class="{cssClasses}">
<f:link.typolink parameter="{image.link}"> <f:link.typolink parameter="{image.link}">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="400c" width="400c"
height="400" height="400"
alt="{image.alternative}" /> alt="{image.alternative}" />
</f:link.typolink> </f:link.typolink>
</div> </div>
</f:section> </f:section>
@@ -13,11 +13,7 @@
<f:variable name="latitude" value="{region.0.data.latitude}"/> <f:variable name="latitude" value="{region.0.data.latitude}"/>
<f:variable name="longitude" value="{region.0.data.longitude}"/> <f:variable name="longitude" value="{region.0.data.longitude}"/>
</f:if> </f:if>
<div class="w-full aspect-w-16 aspect-h-9 mb-8" <f:render partial="Map/Osm" arguments="{settings: settings, class: 'mb-8', zoom: 17, lat: latitude, lng: longitude}"/>
data-controller="osm"
data-osm-lat-value="{latitude}"
data-osm-lng-value="{longitude}"
data-osm-zoom-value="17"></div>
</f:section> </f:section>
</html> </html>
@@ -10,12 +10,11 @@
<f:for each="{items}" as="item" iteration="iteration"> <f:for each="{items}" as="item" iteration="iteration">
<div class="lg:flex odd:flex-row-reverse justify-between items-end mb-16"> <div class="lg:flex odd:flex-row-reverse justify-between items-end mb-16">
<div class="mb-4 lg:mb-0 lg:w-1/2"> <div class="mb-4 lg:mb-0 lg:w-1/2">
<ep:lazyLoadImage image="{item.image.0}" <f:image image="{item.image.0}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="768" width="768"
alt="{item.data.name}" alt="{item.data.name}"/>
title="{item.data.name}" />
</div> </div>
<div class="lg:w-1/2"> <div class="lg:w-1/2">
<div class="px-4 {f:if(condition: iteration.isEven, then: 'lg:pl-12', else: 'lg:pr-12')}"> <div class="px-4 {f:if(condition: iteration.isEven, then: 'lg:pl-12', else: 'lg:pr-12')}">
@@ -1,6 +1,5 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"> xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:layout name="Bare"/> <f:layout name="Bare"/>
@@ -12,12 +11,12 @@
<use href="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/icons_sprite.svg')}#icon-flag-{country.code -> f:format.case(mode: 'lower')}"></use> <use href="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/icons_sprite.svg')}#icon-flag-{country.code -> f:format.case(mode: 'lower')}"></use>
</svg> </svg>
<f:if condition="{country.teaserImages}"> <f:if condition="{country.teaserImages}">
<ep:lazyLoadImage image="{country.teaserImages.0}" <f:image image="{country.teaserImages.0}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-full object-cover" class="block w-full h-full object-cover"
width="400c" width="400c"
height="300c" height="300c"
alt="{country.name}" /> alt="{country.name}" />
</f:if> </f:if>
</div> </div>
<div class="flex-1 flex flex-col pt-4 bg-white border border-t-0 border-zinc-200"> <div class="flex-1 flex flex-col pt-4 bg-white border border-t-0 border-zinc-200">
@@ -26,11 +26,7 @@
<div class="bg-zinc-50 p-4"> <div class="bg-zinc-50 p-4">
<p><strong>Lage</strong></p> <p><strong>Lage</strong></p>
<div> <div>
<div class="w-full aspect-w-16 aspect-h-9" <f:render partial="Map/Osm" arguments="{settings: settings, lat: hotel.latitude, lng: hotel.longitude, externalLink: '{f:uri.typolink(parameter: hotel.externalLink)}'}"/>
data-controller="osm"
data-osm-lat-value="{hotel.latitude}"
data-osm-lng-value="{hotel.longitude}"
data-osm-external-link-value="{f:uri.typolink(parameter: hotel.externalLink)}"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -7,9 +7,7 @@
<f:section name="Main"> <f:section name="Main">
<f:if condition="{markerData}"> <f:if condition="{markerData}">
<ep:container data="{data}"> <ep:container data="{data}">
<div class="w-full aspect-w-16 aspect-h-9 mb-8" <f:render partial="Map/Osm" arguments="{settings: settings, class: 'mb-8', markers: markerData}"/>
data-controller="osm"
data-osm-markers-value='{markerData -> f:format.raw()}'></div>
</ep:container> </ep:container>
</f:if> </f:if>
</f:section> </f:section>
@@ -182,12 +182,11 @@
title="360°-Rundgang {hotel.name}"> title="360°-Rundgang {hotel.name}">
<f:if condition="{product.tourThumbnail}"> <f:if condition="{product.tourThumbnail}">
<f:then> <f:then>
<ep:lazyLoadImage image="{product.tourThumbnail}" <f:image image="{product.tourThumbnail}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="330" width="330"
alt="360°-Rundgang {hotel.name}" alt="360°-Rundgang {hotel.name}"/>
title="360°-Rundgang {hotel.name}"/>
</f:then> </f:then>
<f:else> <f:else>
<div class="button bg-button w-full"> <div class="button bg-button w-full">
@@ -217,12 +216,7 @@
<p class="font-bold mb-2"> <p class="font-bold mb-2">
Lage Lage
</p> </p>
<div class="w-full aspect-w-16 aspect-h-9" <f:render partial="Map/Osm" arguments="{settings: settings, zoom: 15, lat: hotel.latitude, lng: hotel.longitude, externalLink: '{f:uri.typolink(parameter: hotel.externalLink)}'}"/>
data-controller="osm"
data-osm-zoom-value="15"
data-osm-lat-value="{hotel.latitude}"
data-osm-lng-value="{hotel.longitude}"
data-osm-external-link-value="{f:uri.typolink(parameter: hotel.externalLink)}"></div>
</div> </div>
<f:render partial="Region/Webcam" arguments="{region: product.region}"/> <f:render partial="Region/Webcam" arguments="{region: product.region}"/>
<f:render partial="RegionMaps" arguments="{region: product.region}"/> <f:render partial="RegionMaps" arguments="{region: product.region}"/>
@@ -335,11 +329,11 @@
data-lightbox-caption="{image.originalResource.description}" data-lightbox-caption="{image.originalResource.description}"
data-action="lightbox#open" data-action="lightbox#open"
class="{f:if(condition: iteration.isFirst, then: 'col-span-4')}"> class="{f:if(condition: iteration.isFirst, then: 'col-span-4')}">
<ep:lazyLoadImage image="{image}" <f:image image="{image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto" class="block w-full h-auto"
width="{f:if(condition: iteration.isFirst, then: '400', else: '160')}" width="{f:if(condition: iteration.isFirst, then: '400', else: '160')}"
height="{f:if(condition: iteration.isFirst, then: '225c+100', else: '160c+100')}"/> height="{f:if(condition: iteration.isFirst, then: '225c+100', else: '160c+100')}"/>
</a> </a>
</f:for> </f:for>
</div> </div>
@@ -1,7 +1,6 @@
<html data-namespace-typo3-fluid="true" lang="en" <html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers" xmlns:f="http://typo3.org/ns/fluid/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"> xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers">
<f:layout name="Default"/> <f:layout name="Default"/>
@@ -28,13 +27,12 @@
arguments: '{member: member}' arguments: '{member: member}'
)}"> )}">
<f:if condition="{member.image}"> <f:if condition="{member.image}">
<ep:lazyLoadImage image="{member.image}" <f:image image="{member.image}"
loaderimg="EXT:ep_theme/Resources/Public/images/lazy.png" loading="lazy"
class="block w-full h-auto rounded-full" class="block w-full h-auto rounded-full"
width="400" width="400"
height="400c" height="400c"
alt="{member.name}" alt="{member.name}"/>
title="{member.name}" />
</f:if> </f:if>
<div class="text-center text-zinc-800 pt-2"> <div class="text-center text-zinc-800 pt-2">
<span class="block font-bold text-xl"> <span class="block font-bold text-xl">
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
-1
View File
@@ -9,7 +9,6 @@ Encore
.setOutputPath('./public/typo3conf/ext/ep_theme/Resources/Public/') .setOutputPath('./public/typo3conf/ext/ep_theme/Resources/Public/')
.setPublicPath('/typo3conf/ext/ep_theme/Resources/Public/') .setPublicPath('/typo3conf/ext/ep_theme/Resources/Public/')
.addEntry('main', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/main.js') .addEntry('main', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/main.js')
.addEntry('groups-calculator', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/groups-calculator.js')
.addStyleEntry('rte', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/rte.scss') .addStyleEntry('rte', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/rte.scss')
.splitEntryChunks() .splitEntryChunks()
.enableSingleRuntimeChunk() .enableSingleRuntimeChunk()