Compare commits

...
8 Commits
44 changed files with 623 additions and 4465 deletions
-3284
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
16 18
+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",
@@ -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>
@@ -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()
@@ -1098,6 +1098,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 +1143,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)
@@ -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();
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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">
@@ -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">
@@ -335,11 +334,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()