Compare commits

...
32 Commits
Author SHA1 Message Date
fromme 11f46d0acf fix: use utf8mb4 for the database connection and new tables 2026-10-20 11:48:10 +02:00
fromme 4279669aa3 fix: add TCA default 0 to relation selects in TCA overrides for MySQL strict mode 2026-10-20 10:16:40 +02:00
fromme aa3d81102e chore: re-activate chatbot in page footer 2026-10-19 09:12:37 +02:00
frommeandClaude Opus 5.5 739a4c49f7 fix: write cachetool temp script to shared typo3temp
PHP workers with a stale realpath cache still resolve the current symlink
to the previous release and returned 404 for the script placed in the new
one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:21:06 +02:00
fromme 88e0488b49 fix: adopt text orientation in page footer on mobile screens 2026-10-18 18:13:39 +02:00
fromme f57671b4d4 fix: single column in footer menu on mobile screens 2026-10-18 18:11:13 +02:00
fromme 313fb151bc fix: add missing customer code pattern 2026-10-13 17:09:06 +02:00
fromme 6350742084 feat: show pickups table below bus section on product detail pages 2026-10-13 16:10:23 +02:00
fromme ef7d81fe99 feat: remove the feature flag for the pickups table on product detail pages 2026-10-13 14:26:22 +02:00
fromme 0477589a7a feat: fall back to the travel code for dates without a customer selection 2026-10-13 14:26:17 +02:00
fromme 38962845d3 chore: cleanup htaccess 2026-10-08 17:34:15 +02:00
fromme c7f43ad02c feat: add a feature flag for the pickups table on product detail pages 2026-10-08 17:22:21 +02:00
fromme 1111c180f8 feat: hide pickups of group travel dates on product detail pages 2026-10-08 17:20:31 +02:00
fromme 69f9b42757 feat: flag group travel dates from the BusPro customer selection 2026-10-08 16:09:43 +02:00
fromme 5bb4b6254a chore: update http client config 2026-10-08 11:40:57 +02:00
fromme 32b70f9b70 feat: require the CMS API key on /api/hotel and /api/product 2026-10-08 11:37:57 +02:00
fromme 7839b97e63 chore: add a PhpStorm HTTP client suite for the chatbot content feed 2026-10-08 09:41:27 +02:00
fromme 416ac5c13d feat: add a kill switch for the chatbot content feed 2026-10-08 09:36:16 +02:00
fromme 992ec19c33 feat: add the key-protected content-only page type for the chatbot feed 2026-10-08 09:36:12 +02:00
fromme 57b09624f4 feat: serve chatbot content records at /api/content/records 2026-10-08 09:36:06 +02:00
fromme 81724c818b feat: serve the chatbot content manifest at /api/content/manifest 2026-10-08 09:36:01 +02:00
fromme b4a9156df6 feat: add the API key check and settings for the chatbot content feed 2026-10-08 09:35:55 +02:00
fromme 41d188a8cc feat: display pickups data on product detail page
addresses #869cgn2fm
2026-10-07 17:01:13 +02:00
fromme c6ab02305b fix: load captcha image in Chrome on initial page load 2026-10-05 12:01:21 +02:00
fromme 306c6b809f feat: show hotel icons on hotel and product detail pages
addresses #86965w970
2026-10-01 14:17:28 +02:00
fromme 4095c4a068 fix: stop malformed captcha requests from poisoning the page cache 2026-09-30 15:24:50 +02:00
fromme b41aeb7a25 fix: carry pickup times as local clock readings, not instants 2026-09-30 14:57:46 +02:00
fromme c2dc88b8ab fix: match API whitespace handling and skip matchcodes of hidden hotels 2026-09-30 14:57:39 +02:00
fromme 27832d6548 fix: stop truncating imported prices to whole euros 2026-09-30 14:57:20 +02:00
fromme b510299032 feat: import products from the MyE&P export API 2026-09-30 14:57:13 +02:00
fromme 1b6f480bee feat: read the BusPro XML export through XmlProductReader 2026-09-30 14:57:07 +02:00
fromme b9851943d0 fix: key imported services by BusPro id and match selection groups by id 2026-09-30 14:56:50 +02:00
69 changed files with 3661 additions and 389 deletions

No files matched your search

+1
View File
@@ -20,3 +20,4 @@ public/typo3conf/l10n
public/typo3temp public/typo3temp
public/uploads public/uploads
var var
http-client.private.env.json
+37 -7
View File
@@ -1,12 +1,42 @@
# Backchannel endpoints MyE&P reads hotel and product data from. Same environment files and key
# as content-feed.http: pick "local" and put cmsApiKey in http-client.private.env.json.
### GET product data for myep ### GET product data for myep
GET https://ep-reisen.ddev.site/api/product GET {{host}}/api/product?product=3VIIN&hotel=VLTS
?product=SSTMR X-Api-Key: {{cmsApiKey}}
&hotel=SSTGR
&key=abcabc123 > {%
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### GET hotel data for myep ### GET hotel data for myep
GET https://ep-reisen.ddev.site/api/hotel GET {{host}}/api/hotel?hotel=DPWGRP
?hotel=DPWGRP X-Api-Key: {{cmsApiKey}}
&key=abcabc123
> {%
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Transition form: key as query parameter, as MyE&P sends it today (still accepted)
GET {{host}}/api/hotel?hotel=DPWGRP&key={{cmsApiKey}}
> {%
client.test("200 with query key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Any other value is refused now - this used to pass
GET {{host}}/api/hotel?hotel=DPWGRP&key=AbcAbc123
> {%
client.test("403 with the old dummy key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### ###
+141
View File
@@ -0,0 +1,141 @@
# Chatbot content feed (see .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md)
#
# Pick the "local" environment in PhpStorm and run the whole file top to bottom: the manifest
# request stores uids and a content URL that the later requests use.
#
# Needs, locally:
# - http-client.private.env.json with {"local": {"cmsApiKey": "<your key>"}} (gitignored)
# - the same key in TYPO3, e.g.
# ddev exec vendor/bin/typo3cms configuration:set EXTENSIONS/ep_products/cmsApiKey <your key>
# plus contentFeedSites etc. - or open and save the ep_products extension configuration once
# in the backend, which writes all the defaults.
#
# The kill switch (contentFeedEnabled = 0) cannot be toggled from here; with it off, every keyed
# request below answers 503.
### Manifest without a key is refused
GET {{host}}/api/content/manifest
> {%
client.test("403 without key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### Manifest
GET {{host}}/api/content/manifest
X-Api-Key: {{cmsApiKey}}
> {%
client.test("manifest is complete and well-formed", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length > 0, "manifest is empty");
client.assert(response.body.sites.length > 0, "no sites");
client.assert(
response.body.items.every(function (item) { return item.type && item.uid && item.modified; }),
"an item lacks type, uid or modified"
);
});
var uidsOf = function (type, limit) {
return response.body.items
.filter(function (item) { return item.type === type; })
.slice(0, limit)
.map(function (item) { return item.uid; })
.join(",");
};
client.global.set("productUids", uidsOf("product", 5));
client.global.set("hotelUids", uidsOf("hotel", 5));
client.global.set("regionUids", uidsOf("region", 5));
client.global.set("newsUids", uidsOf("news", 3));
var page = response.body.items.filter(function (item) { return item.type === "page" && item.contentUrl; })[0];
client.global.set("contentUrl", page.contentUrl);
var tooMany = [];
for (var i = 1; i <= 101; i++) { tooMany.push(i); }
client.global.set("tooManyUids", tooMany.join(","));
%}
### Products (with FAQs)
GET {{host}}/api/content/records?type=product&uids={{productUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("every requested product comes back, links resolved", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length === client.global.get("productUids").split(",").length, "products missing");
client.assert(JSON.stringify(response.body).indexOf("t3://") === -1, "unresolved t3:// link");
});
%}
### Hotels (codes include matchcode aliases)
GET {{host}}/api/content/records?type=hotel&uids={{hotelUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("hotels carry their codes", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.every(function (hotel) { return Array.isArray(hotel.codes); }), "hotel without codes");
});
%}
### Regions
GET {{host}}/api/content/records?type=region&uids={{regionUids}}
X-Api-Key: {{cmsApiKey}}
### Blog articles
GET {{host}}/api/content/records?type=news&uids={{newsUids}}
X-Api-Key: {{cmsApiKey}}
### Unknown type is a 400
GET {{host}}/api/content/records?type=page&uids=1
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 for unknown type", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### More than 100 uids is a 400
GET {{host}}/api/content/records?type=product&uids={{tooManyUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 above 100 uids", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### Content-only render of the first page in the manifest
GET {{contentUrl}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("bare article, never cached by proxies", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.indexOf("<article>") === 0, "not a bare <article> fragment");
client.assert(response.headers.valueOf("Cache-Control") === "private, no-store", "unexpected Cache-Control");
});
%}
### Content-only render without a key is a 404 (the type must not be discoverable)
GET {{contentUrl}}
> {%
client.test("404 without key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
### Content-only render with a wrong key is a 404
GET {{contentUrl}}
X-Api-Key: definitely-wrong
> {%
client.test("404 with wrong key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
+4 -1
View File
@@ -70,7 +70,10 @@ host('prod')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/{{typo3_webroot}}/ --web-url=https://www.ep-reisen.de') // The temp script goes into the shared typo3temp, which resolves to the same directory from every
// release: PHP workers with a stale realpath cache still map "current" to the previous release and
// would 404 on a script placed inside the new one.
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{deploy_path}}/shared/{{typo3_webroot}}/typo3temp/ --web-url=https://www.ep-reisen.de/typo3temp')
; ;
task('deploy', [ task('deploy', [
+8
View File
@@ -0,0 +1,8 @@
{
"local": {
"host": "https://ep-reisen.ddev.site"
},
"prod": {
"host": "https://www.ep-reisen.de"
}
}
-9
View File
@@ -285,9 +285,6 @@ AddDefaultCharset utf-8
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$"> <FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Origin "*"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(png|gif|jpe?g)$">
Header append Vary Accept
</FilesMatch>
</IfModule> </IfModule>
### End: Cross Origin ### ### End: Cross Origin ###
@@ -395,12 +392,6 @@ AddDefaultCharset utf-8
# NOTE: Add your additional local storages here # NOTE: Add your additional local storages here
RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L] RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L]
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME} (.*)\.(png|gif|jpe?g)$
RewriteCond %{REQUEST_FILENAME}\.webp -f
RewriteRule ^ %{REQUEST_FILENAME}\.webp [L,T=image/webp]
### Begin: StaticFileCache (preparation) #### ### Begin: StaticFileCache (preparation) ####
# Document root configuration # Document root configuration
@@ -90,6 +90,14 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']
// by the CDN, so stop generating them. mod_deflate still compresses direct origin hits. // by the CDN, so stop generating them. mod_deflate still compresses direct origin hits.
$GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 0; $GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 0;
// utf8 in MySQL/MariaDB is utf8mb3 (max. 3 bytes) and rejects 4-byte characters like emoji.
// Set here because LocalConfiguration.php is a Deployer shared file and not deployed from the repo.
$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['charset'] = 'utf8mb4';
$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['tableoptions'] = [
'charset' => 'utf8mb4',
'collate' => 'utf8mb4_unicode_ci',
];
// 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;
@@ -59,6 +59,7 @@ call_user_func(function() {
'label' => 'Reiseart', 'label' => 'Reiseart',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -74,6 +75,7 @@ call_user_func(function() {
'label' => 'Angebot/Beispielangebot', 'label' => 'Angebot/Beispielangebot',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -88,14 +88,14 @@ class DateCommandController extends Command
public function execute(InputInterface $input, OutputInterface $output): int public function execute(InputInterface $input, OutputInterface $output): int
{ {
$importSource = $this->importSourceFactory->get(); $importSource = $this->importSourceFactory->get();
$path = $importSource->acquire(); $import = $importSource->acquire();
if ($path === null) { if ($import === null) {
$output->writeln('No new import available.'); $output->writeln('No new import available.');
return 0; return 0;
} }
$count = $this->dateImportService->import($path); $count = $this->dateImportService->import($import->getRows());
$importSource->markImported($path); $importSource->markImported($import);
$output->writeln(sprintf('%d rows imported.', $count )); $output->writeln(sprintf('%d rows imported.', $count ));
$settings = $this->getSettings(); $settings = $this->getSettings();
$resellerPageUid = $settings['resellerExportPageUid']; $resellerPageUid = $settings['resellerExportPageUid'];
@@ -0,0 +1,165 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's backchannel endpoints under /api/content/.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md.
*/
class ContentFeedApi
{
public const PATH_PREFIX = '/api/content/';
private const MANIFEST_CACHE_IDENTIFIER = 'content_feed_manifest';
// The consumer polls every 15 minutes, so this caps the build at once per poll however
// often it retries. Edits reach the chatbot within about 25 minutes.
private const MANIFEST_CACHE_LIFETIME = 600;
private const MAX_RECORDS_PER_REQUEST = 100;
private const DISABLED_RETRY_AFTER = 3600;
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ManifestBuilder
*/
private $manifestBuilder;
/**
* @var RecordSerializer
*/
private $recordSerializer;
/**
* @var FrontendInterface
*/
private $cache;
public function __construct(
ApiKeyAuthenticator $authenticator,
ContentFeedConfiguration $configuration,
ManifestBuilder $manifestBuilder,
RecordSerializer $recordSerializer,
CacheManager $cacheManager
) {
$this->authenticator = $authenticator;
$this->configuration = $configuration;
$this->manifestBuilder = $manifestBuilder;
$this->recordSerializer = $recordSerializer;
$this->cache = $cacheManager->getCache('ep_products_cache');
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if (!$this->authenticator->isAuthorized($request)) {
return $this->error('Invalid or missing api key', 403);
}
if (!$this->configuration->isEnabled()) {
return self::disabledResponse();
}
if ([] === $this->configuration->getSites()) {
// An empty feed would read as "everything was deleted". Refuse instead.
return $this->error('Content feed has no sites configured', 503);
}
$path = substr($request->getUri()->getPath(), strlen(self::PATH_PREFIX));
if ('manifest' === $path) {
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
}
if ('records' === $path) {
return $this->handleRecordsRequest($request);
}
return $this->error('Not found', 404);
}
/**
* Uids that are not (or no longer) eligible are left out rather than answered with an error:
* the manifest may have changed between the consumer's two calls, and it reads a requested
* but absent uid as deleted.
*/
private function handleRecordsRequest(ServerRequestInterface $request): ResponseInterface
{
$type = (string) ($request->getQueryParams()['type'] ?? '');
if (!isset(RecordFinder::TYPES[$type])) {
return $this->error('Unknown type, expected one of: ' . implode(', ', array_keys(RecordFinder::TYPES)), 400);
}
$uids = array_values(array_unique(array_filter(
GeneralUtility::intExplode(',', (string) ($request->getQueryParams()['uids'] ?? ''), true),
function (int $uid): bool {
return $uid > 0;
}
)));
if ([] === $uids) {
return $this->error('No uids given', 400);
}
if (count($uids) > self::MAX_RECORDS_PER_REQUEST) {
return $this->error(sprintf('At most %d uids per request', self::MAX_RECORDS_PER_REQUEST), 400);
}
return new JsonResponse([
'type' => $type,
'items' => $this->recordSerializer->serialize($type, $uids),
], 200, [], self::JSON_FLAGS);
}
private function getManifest(): array
{
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
if (!is_array($manifest)) {
$manifest = $this->manifestBuilder->build();
$this->cache->set(self::MANIFEST_CACHE_IDENTIFIER, $manifest, [], self::MANIFEST_CACHE_LIFETIME);
}
return $manifest;
}
/**
* The kill switch: contentFeedEnabled = 0 in the extension configuration, for when the host
* is struggling. The consumer stops its run, keeps everything it has and tries again later -
* nothing is deleted on the chatbot side.
*/
public static function disabledResponse(): ResponseInterface
{
return new JsonResponse(
['message' => 'Content feed is switched off'],
503,
['Retry-After' => (string) self::DISABLED_RETRY_AFTER],
self::JSON_FLAGS
);
}
private function error(string $message, int $status): ResponseInterface
{
return new JsonResponse(['message' => $message], $status, [], self::JSON_FLAGS);
}
}
@@ -0,0 +1,75 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's extension configuration.
*
* Missing keys fall back to "feed is on, but covers nothing": a key added to ext_conf_template.txt
* is absent until the extension configuration is saved in the backend, and an empty feed is
* refused by the consumer rather than read as "everything was deleted".
*/
class ContentFeedConfiguration
{
/**
* @var array
*/
private $configuration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->configuration = (array) $extensionConfiguration->get('ep_products');
}
public function isEnabled(): bool
{
return (bool) ($this->configuration['contentFeedEnabled'] ?? true);
}
/**
* @return string[] site identifiers
*/
public function getSites(): array
{
return GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedSites'] ?? ''), true);
}
/**
* @return int[] language ids; the default language when unset
*/
public function getLanguages(): array
{
$languages = GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedLanguages'] ?? '0'), true);
return [] === $languages ? [0] : $languages;
}
/**
* @return int[] page uids whose whole subtree stays out of the feed
*/
public function getExcludedPages(): array
{
return GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedExcludedPages'] ?? ''), true);
}
/**
* @return array<string, int> site identifier => news detail page uid
*/
public function getNewsDetailPages(): array
{
$pages = [];
foreach (GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedNewsDetailPages'] ?? ''), true) as $entry) {
$parts = GeneralUtility::trimExplode(':', $entry, true);
if (2 === count($parts) && (int) $parts[1] > 0) {
$pages[$parts[0]] = (int) $parts[1];
}
}
return $pages;
}
}
@@ -0,0 +1,379 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* Builds GET /api/content/manifest: everything the content feed publishes, with a modification
* marker per item and no bodies. The list is complete - an item missing from it is gone - so the
* consumer never has to infer deletions from timestamps.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §4.
*/
class ManifestBuilder
{
public const CONTENT_PAGE_TYPE = 1730;
private const DOKTYPE_DEFAULT = 1;
// Recycler and backend user section: nothing below them is public.
private const DOKTYPES_NOT_TRAVERSED = [6, 255];
// l18n_cfg bit 1: "hide default translation of page".
private const L18N_HIDE_DEFAULT_LANGUAGE = 1;
private const RECORD_FIELDS = [
'product' => ['uid', 'tstamp', 'detail_page'],
'hotel' => ['uid', 'tstamp', 'detail_page'],
'region' => ['uid', 'tstamp', 'detail_page'],
'country' => ['uid', 'tstamp', 'detail_page'],
'city' => ['uid', 'tstamp', 'detail_page'],
'concept' => ['uid', 'tstamp', 'detail_page'],
'news' => ['uid', 'tstamp', 'pid'],
];
/**
* @var PageTree
*/
private $pageTree;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
PageTree $pageTree,
UrlResolver $urlResolver,
RecordFinder $recordFinder,
ContentFeedConfiguration $configuration,
ConnectionPool $connectionPool
) {
$this->pageTree = $pageTree;
$this->urlResolver = $urlResolver;
$this->recordFinder = $recordFinder;
$this->configuration = $configuration;
$this->connectionPool = $connectionPool;
}
public function build(): array
{
$sites = $this->urlResolver->getIncludedSites();
$records = $this->buildRecordItems();
return [
'generatedAt' => date('c'),
'sites' => array_map([$this, 'describeSite'], $sites),
'items' => array_merge($this->buildPageItems($sites, $this->collectDetailPages()), $records),
];
}
private function describeSite(Site $site): array
{
$languages = [];
foreach ($site->getLanguages() as $language) {
$languages[] = $language->getTwoLetterIsoCode();
}
$title = trim((string) ($site->getConfiguration()['websiteTitle'] ?? ''));
if ('' === $title) {
$title = trim($site->getDefaultLanguage()->getWebsiteTitle());
}
return [
'identifier' => $site->getIdentifier(),
'title' => '' !== $title ? $title : $site->getIdentifier(),
'base' => (string) $site->getBase(),
'languages' => $languages,
];
}
/**
* @param Site[] $sites
* @param array<int, array> $detailPages detailOf entries by page uid
*/
private function buildPageItems(array $sites, array $detailPages): array
{
$content = $this->findContentStatistics();
$excluded = array_flip($this->configuration->getExcludedPages());
$feedLanguages = $this->configuration->getLanguages();
$items = [];
foreach ($sites as $site) {
$languageIds = [];
foreach ($site->getLanguages() as $language) {
if (in_array($language->getLanguageId(), $feedLanguages, true)) {
$languageIds[$language->getLanguageId()] = $language->getTwoLetterIsoCode();
}
}
$context = [
'site' => $site,
'languages' => $languageIds,
'excluded' => $excluded,
'content' => $content,
'detailPages' => $detailPages,
];
$breadcrumbs = array_fill_keys(array_keys($languageIds), []);
$this->walk($site->getRootPageId(), $context, $breadcrumbs, true, $items);
}
return $items;
}
/**
* @param array<int, string[]> $breadcrumbs titles above this page, by language id
*/
private function walk(int $uid, array $context, array $breadcrumbs, bool $isRoot, array &$items): void
{
$page = $this->pageTree->getPage($uid);
if (null === $page || isset($context['excluded'][$uid])) {
return;
}
if (in_array((int) $page['doktype'], self::DOKTYPES_NOT_TRAVERSED, true)) {
return;
}
$visible = $this->pageTree->isVisible($page);
if (!$visible && (int) $page['extendToSubpages'] === 1) {
return;
}
$childBreadcrumbs = $breadcrumbs;
foreach ($context['languages'] as $languageId => $isoCode) {
$row = $this->getLanguageRow($page, $languageId);
if ($visible && null !== $row && $this->isPublishable($page, $row, $languageId)) {
$item = $this->buildPageItem($page, $row, $languageId, $isoCode, $breadcrumbs[$languageId], $context);
if (null !== $item) {
$items[] = $item;
}
}
if (!$isRoot) {
$source = $row ?? $page;
$childBreadcrumbs[$languageId][] = '' !== trim((string) $source['nav_title'])
? trim((string) $source['nav_title'])
: trim((string) $source['title']);
}
}
foreach ($this->pageTree->getChildren($uid) as $childUid) {
$this->walk($childUid, $context, $childBreadcrumbs, false, $items);
}
}
private function getLanguageRow(array $page, int $languageId): ?array
{
if (0 === $languageId) {
return $page;
}
$overlay = $this->pageTree->getOverlay((int) $page['uid'], $languageId);
return null !== $overlay && $this->pageTree->isVisible($overlay) ? $overlay : null;
}
/**
* Mirrors the XML sitemap: standard pages only, nothing marked no_index. If Google is not
* meant to see a page, the chatbot is not either.
*/
private function isPublishable(array $page, array $row, int $languageId): bool
{
if ((int) $page['doktype'] !== self::DOKTYPE_DEFAULT || (int) $row['no_index'] === 1) {
return false;
}
return 0 !== $languageId || 0 === ((int) $page['l18n_cfg'] & self::L18N_HIDE_DEFAULT_LANGUAGE);
}
private function buildPageItem(array $page, array $row, int $languageId, string $isoCode, array $breadcrumb, array $context): ?array
{
$uid = (int) $page['uid'];
// "Show content from page": the elements live on another page, and so do their changes.
// The site answers 404 when that page is hidden.
$contentPid = (int) $page['content_from_pid'] ?: $uid;
$contentPage = $this->pageTree->getPage($contentPid);
if (null === $contentPage || !$this->pageTree->isVisible($contentPage)) {
return null;
}
// Without a visible element other than plugins, the content-only render is empty (it
// leaves plugins out), so there is nothing to fetch. That is most product and hotel
// detail pages: their text comes through the records endpoint.
$content = $context['content'][$contentPid] ?? [];
$textElements = ($content[$languageId]['text'] ?? 0) + ($content[-1]['text'] ?? 0);
if (0 === $textElements) {
return null;
}
$url = $this->urlResolver->getPageUrl($uid, $languageId);
if (null === $url) {
return null;
}
$modified = max(
(int) $row['tstamp'],
$content[$languageId]['tstamp'] ?? 0,
$content[-1]['tstamp'] ?? 0
);
return [
'type' => 'page',
'uid' => $uid,
'language' => $isoCode,
'site' => $context['site']->getIdentifier(),
'url' => $url,
'contentUrl' => $this->urlResolver->getPageUrl($uid, $languageId, ['type' => self::CONTENT_PAGE_TYPE]),
'title' => trim((string) $row['title']),
'description' => $this->nullIfEmpty((string) $row['description']),
'breadcrumb' => $breadcrumb,
'detailOf' => $context['detailPages'][$uid] ?? [],
'modified' => date('c', $modified),
];
}
/**
* Per page and language: the latest change to any content element, and how many visible
* elements are not plugins.
* Deleted and hidden elements count towards the change - removing or hiding one changes the
* page - which is also why SYS_LASTCHANGED is no use here: it only moves on render.
*
* @return array<int, array<int, array{tstamp: int, text: int}>>
*/
private function findContentStatistics(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral(
'pid',
'sys_language_uid',
'MAX(tstamp) AS tstamp',
"SUM(CASE WHEN deleted = 0 AND hidden = 0 AND CType <> 'list' THEN 1 ELSE 0 END) AS text_elements"
)
->from('tt_content')
->groupBy('pid', 'sys_language_uid')
->execute()
->fetchAllAssociative()
;
$statistics = [];
foreach ($rows as $row) {
$statistics[(int) $row['pid']][(int) $row['sys_language_uid']] = [
'tstamp' => (int) $row['tstamp'],
'text' => (int) $row['text_elements'],
];
}
return $statistics;
}
/**
* Which records name a page as their detail page. Keyed by the page the record points at,
* not by where a shortcut leads: a shortcut is not in the manifest, and the page it leads to
* usually describes something broader (product detail shortcuts point at region pages).
*
* @return array<int, array>
*/
private function collectDetailPages(): array
{
$detailPages = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
if (!in_array('detail_page', $fields, true)) {
continue;
}
foreach ($this->recordFinder->find($type, ['uid', 'detail_page']) as $uid => $row) {
$pageUid = $this->urlResolver->parsePageReference((string) $row['detail_page']);
if (null !== $pageUid) {
$detailPages[$pageUid][] = ['type' => $type, 'uid' => $uid];
}
}
}
return $detailPages;
}
private function buildRecordItems(): array
{
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$items = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
foreach ($this->recordFinder->find($type, $fields) as $uid => $row) {
if ('news' === $type) {
$link = $this->urlResolver->getNewsLink((int) $row['uid'], (int) $row['pid']);
// News has no detail_page of its own: an article stored outside the feed's
// sites (the retired ep-events) is not part of the feed at all.
if (null === $link) {
continue;
}
} else {
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
}
$modified = (int) $row['tstamp'];
if ('product' === $type) {
$modified = max($modified, $faqTimestamps[$uid] ?? 0);
}
if ('hotel' === $type) {
$modified = max($modified, $matchcodeTimestamps[$uid] ?? 0);
}
$items[] = [
'type' => $type,
'uid' => $uid,
'language' => 'de',
'site' => $link['site'],
'url' => $link['url'],
'modified' => date('c', $modified),
];
}
}
return $items;
}
private function nullIfEmpty(string $value): ?string
{
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,131 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
/**
* The whole page table in memory, for the content feed's tree walk and shortcut resolution.
*
* About 2 900 rows: one query and a PHP walk is far cheaper than a query per tree level.
* Visibility is evaluated here, not in SQL, because a hidden page still has to be traversed
* (its subpages stay reachable unless extendToSubpages is set).
*/
class PageTree
{
private const FIELDS = [
'uid', 'pid', 'sorting', 'doktype', 'hidden', 'starttime', 'endtime', 'fe_group',
'extendToSubpages', 'no_index', 'title', 'nav_title', 'description', 'shortcut',
'shortcut_mode', 'content_from_pid', 'tstamp', 'sys_language_uid', 'l10n_parent', 'l18n_cfg',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
/**
* @var int
*/
private $now;
/**
* @var array<int, array>|null default-language rows by uid
*/
private $pages;
/**
* @var array<int, int[]> child uids by parent uid, in sorting order
*/
private $children = [];
/**
* @var array<int, array<int, array>> overlay rows by default-language uid and language id
*/
private $overlays = [];
public function __construct(ConnectionPool $connectionPool, Context $context)
{
$this->connectionPool = $connectionPool;
$this->now = (int) $context->getPropertyFromAspect('date', 'timestamp');
}
public function getPage(int $uid): ?array
{
$this->load();
return $this->pages[$uid] ?? null;
}
public function getOverlay(int $uid, int $languageId): ?array
{
$this->load();
return $this->overlays[$uid][$languageId] ?? null;
}
/**
* @return int[]
*/
public function getChildren(int $uid): array
{
$this->load();
return $this->children[$uid] ?? [];
}
/**
* Visible to an anonymous frontend visitor: not hidden, inside its start/end window, and not
* restricted to a frontend user group.
*/
public function isVisible(array $row): bool
{
if ((int) $row['hidden'] === 1) {
return false;
}
if ((int) $row['starttime'] > $this->now) {
return false;
}
if ((int) $row['endtime'] !== 0 && (int) $row['endtime'] <= $this->now) {
return false;
}
return in_array((string) $row['fe_group'], ['', '0'], true);
}
private function load(): void
{
if (null !== $this->pages) {
return;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction());
$rows = $queryBuilder
->select(...self::FIELDS)
->from('pages')
->orderBy('sorting')
->execute()
->fetchAllAssociative()
;
$this->pages = [];
foreach ($rows as $row) {
$uid = (int) $row['uid'];
if ((int) $row['sys_language_uid'] > 0) {
$this->overlays[(int) $row['l10n_parent']][(int) $row['sys_language_uid']] = $row;
continue;
}
$this->pages[$uid] = $row;
$this->children[(int) $row['pid']][] = $uid;
}
}
}
@@ -0,0 +1,152 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Which records the content feed publishes. The manifest and the records endpoint both go
* through here, so a uid the manifest lists is exactly a uid the records endpoint returns.
*
* The default restrictions apply: deleted, hidden, starttime and endtime.
*/
class RecordFinder
{
public const TYPES = [
'product' => 'tx_epproducts_domain_model_product',
'hotel' => 'tx_epproducts_domain_model_hotel',
'region' => 'tx_epproducts_domain_model_region',
'country' => 'tx_epproducts_domain_model_country',
'city' => 'tx_epproducts_domain_model_city',
'concept' => 'tx_epproducts_domain_model_concept',
'news' => 'tx_news_domain_model_news',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(ConnectionPool $connectionPool)
{
$this->connectionPool = $connectionPool;
}
/**
* @param string[] $fields
* @param int[]|null $uids null for all eligible records
* @return array<int, array> rows by uid, in uid order
*/
public function find(string $type, array $fields, ?array $uids = null): array
{
$table = self::TYPES[$type];
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$queryBuilder
->select(...$fields)
->from($table)
->where($queryBuilder->expr()->in('sys_language_uid', [0, -1]))
->orderBy('uid')
;
if (null !== $uids) {
if ([] === $uids) {
return [];
}
$queryBuilder->andWhere($queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)
));
}
$this->addTypeConstraints($type, $queryBuilder);
$rows = [];
foreach ($queryBuilder->execute()->fetchAllAssociative() as $row) {
$rows[(int) $row['uid']] = $row;
}
return $rows;
}
/**
* Latest change to a product's FAQs, by product uid. Deleted and hidden FAQs count: removing
* one is a change to the product's text.
*
* @return array<int, int>
*/
public function findFaqTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('mm.uid_local AS product', 'MAX(faq.tstamp) AS tstamp')
->from('tx_epproducts_product_faq_mm', 'mm')
->join('mm', 'tx_epproducts_domain_model_faq', 'faq', 'faq.uid = mm.uid_foreign')
->groupBy('mm.uid_local')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'product');
}
/**
* Latest change to a hotel's matchcode aliases, by hotel uid. They are part of the hotel's
* `codes`, which the consumer joins its trip data on.
*
* @return array<int, int>
*/
public function findMatchcodeTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('entity AS hotel', 'MAX(tstamp) AS tstamp')
->from('tx_epproducts_domain_model_matchcode')
->where($queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')))
->groupBy('entity')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'hotel');
}
/**
* @return array<int, int>
*/
private function indexTimestamps(array $rows, string $key): array
{
$timestamps = [];
foreach ($rows as $row) {
$timestamps[(int) $row[$key]] = (int) $row['tstamp'];
}
return $timestamps;
}
private function addTypeConstraints(string $type, QueryBuilder $queryBuilder): void
{
$expr = $queryBuilder->expr();
if ('product' === $type) {
// A product that is neither searchable nor bookable is an internal record.
$queryBuilder->andWhere($expr->orX($expr->eq('searchable', 1), $expr->eq('bookable', 1)));
}
if ('news' === $type) {
// Type 0 is a regular article; 1 and 2 are just links to a page or an external URL.
$queryBuilder->andWhere(
$expr->eq('type', $queryBuilder->createNamedParameter('0')),
$expr->in('fe_group', $queryBuilder->createNamedParameter(['', '0'], Connection::PARAM_STR_ARRAY))
);
}
}
}
@@ -0,0 +1,513 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Builds GET /api/content/records: the editorial content of products, hotels, destinations,
* concepts and blog articles. Filter attributes and text only - no dates, prices or images.
* Dates and prices are the consumer's own data; images play no part in a text chatbot.
*
* Rich-text fields go out as stored HTML with `t3://` links resolved; the consumer converts
* them to Markdown together with the rendered pages, so there is one HTML-to-text step.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §5.
*/
class RecordSerializer
{
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RteLinkResolver
*/
private $rteLinkResolver;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
RecordFinder $recordFinder,
UrlResolver $urlResolver,
RteLinkResolver $rteLinkResolver,
ConnectionPool $connectionPool
) {
$this->recordFinder = $recordFinder;
$this->urlResolver = $urlResolver;
$this->rteLinkResolver = $rteLinkResolver;
$this->connectionPool = $connectionPool;
}
/**
* @param int[] $uids
* @return array[] one item per eligible uid; ineligible uids are left out
*/
public function serialize(string $type, array $uids): array
{
$rows = $this->recordFinder->find($type, ['*'], $uids);
if ([] === $rows) {
return [];
}
switch ($type) {
case 'product':
return $this->serializeProducts($rows);
case 'hotel':
return $this->serializeHotels($rows);
case 'news':
return $this->serializeNews($rows);
default:
return array_values(array_map(function (array $row) use ($type): array {
return $this->serializeDestination($type, $row);
}, $rows));
}
}
private function serializeProducts(array $rows): array
{
$uids = array_keys($rows);
$hotels = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_local', 'uid_foreign', $uids);
$regions = $this->findMmUids('tx_epproducts_product_region_mm', 'uid_local', 'uid_foreign', $uids);
$faqs = $this->findFaqs($uids);
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$infoboxes = $this->findInfoboxTexts(array_column($rows, 'non_bookable_info'));
$items = [];
foreach ($rows as $uid => $row) {
$season = trim((string) $row['season']);
$items[] = $this->withLink($row, max((int) $row['tstamp'], $faqTimestamps[$uid] ?? 0)) + [
'code' => $this->text($row['code']),
'busProId' => (int) $row['bus_pro_id'] ?: null,
'name' => $this->text($row['name']),
'subline' => $this->text($row['subline']),
'headline' => $this->text($row['headline']),
'season' => '' === $season ? 'w' : $season,
'daytrip' => (bool) $row['daytrip'],
'bookable' => (bool) $row['bookable'],
'searchable' => (bool) $row['searchable'],
'skipassIncluded' => (bool) $row['skipass_included'],
'conceptUid' => (int) $row['concept'] ?: null,
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'additionalRegionUids' => $regions[$uid] ?? [],
'hotelUids' => $hotels[$uid] ?? [],
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'teaserLong' => $this->html($row['teaser_long']),
'feature' => $this->text($row['feature']),
'conceptDescription' => $this->html($row['concept_description']),
'boardDescription' => $this->html($row['board_description']),
'includedServices' => $this->html($row['included_services']),
'additionalServices' => $this->html($row['additional_services']),
'programmeDescription' => $this->html($row['programme_description']),
'skiPassDescription' => $this->html($row['ski_pass_description']),
'nonBookableInfo' => $infoboxes[(int) $row['non_bookable_info']] ?? null,
],
'faqs' => $faqs[$uid] ?? [],
];
}
return $items;
}
private function serializeHotels(array $rows): array
{
$uids = array_keys($rows);
$aliases = $this->findMatchcodes($uids);
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$products = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_foreign', 'uid_local', $uids, 'sorting_foreign');
$eligibleProducts = $this->recordFinder->find('product', ['uid'], array_values(array_unique(array_merge([], ...array_values($products)))));
$items = [];
foreach ($rows as $uid => $row) {
$code = trim((string) $row['code']);
$codes = array_values(array_unique(array_filter(array_merge([$code], $aliases[$uid] ?? []), 'strlen')));
$items[] = $this->withLink($row, max((int) $row['tstamp'], $matchcodeTimestamps[$uid] ?? 0)) + [
'code' => '' === $code ? null : $code,
'codes' => $codes,
'name' => $this->text($row['name']),
'shortName' => $this->text($row['short_name']),
'headline' => $this->text($row['headline']),
'type' => (int) $row['type'],
'category' => (int) $row['category'],
'new' => (bool) $row['new'],
'earlybird' => (bool) $row['earlybird'],
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'productUids' => array_values(array_filter($products[$uid] ?? [], function (int $productUid) use ($eligibleProducts): bool {
return isset($eligibleProducts[$productUid]);
})),
'address' => $this->text($row['address']),
'latitude' => $this->text($row['latitude']),
'longitude' => $this->text($row['longitude']),
'facts' => [
'bedsCount' => (int) $row['beds_count'] ?: null,
'parkingCount' => (int) $row['parking_count'] ?: null,
'altitude' => $this->text($row['altitude']),
'distanceBus' => $this->text($row['distance_bus']),
'distanceLift' => $this->text($row['distance_lift']),
'shower' => $this->text($row['shower']),
'partyroom' => (bool) $row['partyroom'],
'gamesroom' => (bool) $row['gamesroom'],
'sauna' => (bool) $row['sauna'],
'wifi' => (bool) $row['wifi'],
],
'icons' => HotelIconUtility::getIcons($row),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'features' => $this->html($row['features']),
'roomTypes' => $this->html($row['room_types']),
'additionalInformation' => $this->html($row['additional_information']),
],
];
}
return $items;
}
private function serializeDestination(string $type, array $row): array
{
$item = $this->withLink($row, (int) $row['tstamp']) + [
'name' => $this->text($row['name']),
'headline' => $this->text($row['headline']),
];
switch ($type) {
case 'region':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'facts' => [
'length' => $this->text($row['length']),
'height' => $this->text($row['height']),
'lifts' => $this->text($row['lifts']),
],
'webcam' => $this->findWebcamUrl((int) $row['webcam']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'feature' => $this->text($row['feature']),
'skiArea' => $this->html($row['ski_area']),
'skiAreaExtended' => $this->html($row['ski_area_extended']),
'skiPass' => $this->html($row['ski_pass']),
'news' => $this->html($row['news']),
],
];
case 'country':
return $item + [
'code' => $this->text($row['code']),
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
],
];
case 'city':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'party' => $this->html($row['party']),
'leisure' => $this->html($row['leisure']),
],
];
default: // concept
return $item + [
'code' => $this->text($row['code']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'popupText' => $this->html($row['popup_text']),
],
];
}
}
private function serializeNews(array $rows): array
{
$uids = array_keys($rows);
$tags = $this->findNewsTags($uids);
$categories = $this->findNewsCategories($uids);
$items = [];
foreach ($rows as $uid => $row) {
$link = $this->urlResolver->getNewsLink($uid, (int) $row['pid']);
if (null === $link) {
continue;
}
$items[] = [
'uid' => $uid,
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', (int) $row['tstamp']),
'title' => $this->text($row['title']),
'teaser' => $this->html($row['teaser']),
'bodytext' => $this->html($row['bodytext']),
'datetime' => (int) $row['datetime'] > 0 ? date('c', (int) $row['datetime']) : null,
'tags' => $tags[$uid] ?? [],
'categories' => $categories[$uid] ?? [],
];
}
return $items;
}
/**
* The identity and link fields every record carries, with the same values as the manifest.
*/
private function withLink(array $row, int $modified): array
{
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
return [
'uid' => (int) $row['uid'],
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', $modified),
];
}
/**
* @param int[] $uids
* @return array<int, int[]> related uids by $keyField, in $sortField order
*/
private function findMmUids(string $table, string $keyField, string $valueField, array $uids, string $sortField = 'sorting'): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$rows = $queryBuilder
->select($keyField, $valueField)
->from($table)
->where($queryBuilder->expr()->in($keyField, $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)))
->orderBy($keyField)
->addOrderBy($sortField)
->execute()
->fetchAllAssociative()
;
$related = [];
foreach ($rows as $row) {
$related[(int) $row[$keyField]][] = (int) $row[$valueField];
}
return $related;
}
/**
* @param int[] $productUids
* @return array<int, array[]> FAQs by product uid, in the order the editor arranged them
*/
private function findFaqs(array $productUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$rows = $queryBuilder
->select('mm.uid_local AS product', 'faq.question', 'faq.answer', 'faq.category')
->from('tx_epproducts_domain_model_faq', 'faq')
->join('faq', 'tx_epproducts_product_faq_mm', 'mm', 'mm.uid_foreign = faq.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.uid_local')
->addOrderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$faqs = [];
foreach ($rows as $row) {
$faqs[(int) $row['product']][] = [
'question' => $this->text($row['question']),
'answer' => $this->html($row['answer']),
'category' => $this->text($row['category']),
];
}
return $faqs;
}
/**
* Matchcode aliases by hotel uid. With the hotel's own code they are every code BusPro may use
* for it - the same set DateImportService::getHotelMappings() resolves.
*
* @param int[] $hotelUids
* @return array<int, string[]>
*/
private function findMatchcodes(array $hotelUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$rows = $queryBuilder
->select('entity', 'code')
->from('tx_epproducts_domain_model_matchcode')
->where(
$queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')),
$queryBuilder->expr()->in('entity', $queryBuilder->createNamedParameter($hotelUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('code')
->execute()
->fetchAllAssociative()
;
$codes = [];
foreach ($rows as $row) {
$codes[(int) $row['entity']][] = trim((string) $row['code']);
}
return $codes;
}
/**
* @param array $infoboxUids
* @return array<int, string|null>
*/
private function findInfoboxTexts(array $infoboxUids): array
{
$infoboxUids = array_values(array_filter(array_map('intval', $infoboxUids)));
if ([] === $infoboxUids) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_infobox');
$rows = $queryBuilder
->select('uid', 'text')
->from('tx_epproducts_domain_model_infobox')
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter(array_unique($infoboxUids), Connection::PARAM_INT_ARRAY)))
->execute()
->fetchAllAssociative()
;
$texts = [];
foreach ($rows as $row) {
$texts[(int) $row['uid']] = $this->html($row['text']);
}
return $texts;
}
private function findWebcamUrl(int $webcamUid): ?string
{
if (0 === $webcamUid) {
return null;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_webcam');
$url = $queryBuilder
->select('url')
->from('tx_epproducts_domain_model_webcam')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($webcamUid, \PDO::PARAM_INT)))
->execute()
->fetchOne()
;
return false === $url ? null : $this->text($url);
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsTags(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_tag');
$rows = $queryBuilder
->select('mm.uid_local AS news', 'tag.title')
->from('tx_news_domain_model_tag', 'tag')
->join('tag', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = tag.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$tags = [];
foreach ($rows as $row) {
$tags[(int) $row['news']][] = trim((string) $row['title']);
}
return $tags;
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsCategories(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('mm.uid_foreign AS news', 'category.title')
->from('sys_category', 'category')
->join('category', 'sys_category_record_mm', 'mm', 'mm.uid_local = category.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news')),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories')),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('mm.sorting_foreign')
->execute()
->fetchAllAssociative()
;
$categories = [];
foreach ($rows as $row) {
$categories[(int) $row['news']][] = trim((string) $row['title']);
}
return $categories;
}
/**
* Rich text as stored, with internal links made absolute.
*/
private function html($value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $this->rteLinkResolver->resolve($value);
}
/**
* Plain text with whitespace collapsed. Product names carry literal line breaks the website
* splits on for layout ("Apartments \nVal Thorens"); the chatbot needs them as one line.
*/
private function text($value): ?string
{
$value = trim((string) preg_replace('/\s+/u', ' ', (string) $value));
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,104 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
/**
* Rewrites `t3://` links in stored RTE HTML to absolute URLs.
*
* parseFunc_RTE would do this, but it needs a TSFE and the API middleware runs before one exists.
* Only links are touched; the rest of the HTML is passed on as stored. A link that cannot be
* resolved to something public (a page outside the feed's sites, a deleted page) loses its href
* and keeps its text.
*/
class RteLinkResolver
{
/**
* @var LinkService
*/
private $linkService;
/**
* @var UrlResolver
*/
private $urlResolver;
public function __construct(LinkService $linkService, UrlResolver $urlResolver)
{
$this->linkService = $linkService;
$this->urlResolver = $urlResolver;
}
public function resolve(string $html): string
{
if (false === strpos($html, 't3://')) {
return $html;
}
return (string) preg_replace_callback(
'/\shref=(["\'])(t3:\/\/[^"\']*)\1/i',
function (array $matches): string {
$url = $this->resolveLink(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5));
return null === $url ? '' : ' href="' . htmlspecialchars($url) . '"';
},
$html
);
}
private function resolveLink(string $link): ?string
{
try {
$data = $this->linkService->resolve($link);
} catch (\Throwable $e) {
return null;
}
if (LinkService::TYPE_PAGE === ($data['type'] ?? null)) {
$pageUid = $this->urlResolver->parsePageReference('t3://page?uid=' . ($data['pageuid'] ?? ''));
$target = null === $pageUid ? null : $this->urlResolver->resolveLinkTarget($pageUid);
$url = null === $target ? null : $this->urlResolver->getPageUrl($target);
if (null !== $url && '' !== (string) ($data['fragment'] ?? '')) {
$url .= '#' . $data['fragment'];
}
return $url;
}
if (LinkService::TYPE_FILE === ($data['type'] ?? null) && ($data['file'] ?? null) instanceof File) {
return $this->absolute((string) $data['file']->getPublicUrl());
}
if (LinkService::TYPE_URL === ($data['type'] ?? null)) {
return (string) $data['url'];
}
return null;
}
/**
* File URLs come back relative to the document root; the consumer has no page to resolve
* them against for record text, so make them absolute against the main site.
*/
private function absolute(string $url): ?string
{
if ('' === $url) {
return null;
}
if (1 === preg_match('/^https?:\/\//', $url)) {
return $url;
}
$sites = $this->urlResolver->getIncludedSites();
if ([] === $sites) {
return null;
}
return rtrim((string) $sites[0]->getBase(), '/') . '/' . ltrim($url, '/');
}
}
@@ -0,0 +1,214 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Absolute public URLs for the content feed, generated through each site's router so that
* baseVariants apply (staging and DDEV get their own hosts) and route enhancers shape the path.
*/
class UrlResolver
{
private const DOKTYPE_DEFAULT = 1;
private const DOKTYPE_SHORTCUT = 4;
private const SHORTCUT_MODE_NONE = 0;
private const SHORTCUT_MODE_FIRST_SUBPAGE = 1;
private const SHORTCUT_MODE_PARENT_PAGE = 3;
private const MAX_SHORTCUT_DEPTH = 5;
/**
* @var SiteFinder
*/
private $siteFinder;
/**
* @var PageTree
*/
private $pageTree;
/**
* @var string[]
*/
private $includedSites;
/**
* @var array<string, int> news detail page uid by site identifier
*/
private $newsDetailPages;
/**
* @var array<int, Site|null>
*/
private $sitesByPage = [];
public function __construct(SiteFinder $siteFinder, PageTree $pageTree, ContentFeedConfiguration $configuration)
{
$this->siteFinder = $siteFinder;
$this->pageTree = $pageTree;
$this->includedSites = $configuration->getSites();
$this->newsDetailPages = $configuration->getNewsDetailPages();
}
/**
* @return Site[] included sites, in configuration order
*/
public function getIncludedSites(): array
{
$sites = [];
foreach ($this->includedSites as $identifier) {
try {
$sites[] = $this->siteFinder->getSiteByIdentifier($identifier);
} catch (SiteNotFoundException $e) {
// A misspelt identifier must not take the whole feed down; it just contributes nothing.
}
}
return $sites;
}
/**
* The page's site, or null when it belongs to no site or to one outside the feed.
*/
public function getIncludedSite(int $pageUid): ?Site
{
if (!array_key_exists($pageUid, $this->sitesByPage)) {
try {
$site = $this->siteFinder->getSiteByPageId($pageUid);
$this->sitesByPage[$pageUid] = in_array($site->getIdentifier(), $this->includedSites, true) ? $site : null;
} catch (SiteNotFoundException $e) {
$this->sitesByPage[$pageUid] = null;
}
}
return $this->sitesByPage[$pageUid];
}
public function getPageUrl(int $pageUid, int $languageId = 0, array $parameters = []): ?string
{
$site = $this->getIncludedSite($pageUid);
if (null === $site) {
return null;
}
try {
$language = $site->getLanguageById($languageId);
return (string) $site->getRouter()->generateUri($pageUid, ['_language' => $language] + $parameters);
} catch (\Throwable $e) {
// Unroutable pages (missing slug, disabled language) are left out rather than failing the feed.
return null;
}
}
/**
* Parses a typolink page reference as stored in `detail_page`: a plain uid or `t3://page?uid=N`.
*/
public function parsePageReference(string $reference): ?int
{
$reference = trim($reference);
if (MathUtility::canBeInterpretedAsInteger($reference)) {
return (int) $reference > 0 ? (int) $reference : null;
}
if (1 === preg_match('/^t3:\/\/page\?(?:.*&)?uid=(\d+)/', html_entity_decode($reference), $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Follows shortcut pages the way a typolink does, and returns the uid of the visible standard
* page a link to $pageUid ends up on - or null when the chain breaks or loops.
*/
public function resolveLinkTarget(int $pageUid): ?int
{
for ($depth = 0; $depth < self::MAX_SHORTCUT_DEPTH; $depth++) {
$page = $this->pageTree->getPage($pageUid);
if (null === $page || !$this->pageTree->isVisible($page)) {
return null;
}
if ((int) $page['doktype'] !== self::DOKTYPE_SHORTCUT) {
return (int) $page['doktype'] === self::DOKTYPE_DEFAULT ? $pageUid : null;
}
$next = $this->getShortcutTarget($page);
if (null === $next) {
return null;
}
$pageUid = $next;
}
return null;
}
/**
* @return array{url: ?string, site: ?string}
*/
public function getRecordLink(string $detailPage): array
{
$pageUid = $this->parsePageReference($detailPage);
$target = null === $pageUid ? null : $this->resolveLinkTarget($pageUid);
$site = null === $target ? null : $this->getIncludedSite($target);
$url = null === $site ? null : $this->getPageUrl($target);
return [
'url' => $url,
'site' => null === $url ? null : $site->getIdentifier(),
];
}
/**
* News has no detail_page of its own: the storage folder decides the site, and the site's
* configured detail page carries the article through the NewsDetailPlugin route enhancer.
*
* @return array{url: ?string, site: string}|null null when the article is stored outside the feed's sites
*/
public function getNewsLink(int $newsUid, int $storagePid): ?array
{
$site = $this->getIncludedSite($storagePid);
if (null === $site) {
return null;
}
$detailPage = $this->newsDetailPages[$site->getIdentifier()] ?? null;
$url = null === $detailPage ? null : $this->getPageUrl($detailPage, 0, [
'tx_news_pi1' => ['action' => 'detail', 'controller' => 'News', 'news' => $newsUid],
]);
return ['url' => $url, 'site' => $site->getIdentifier()];
}
private function getShortcutTarget(array $page): ?int
{
switch ((int) $page['shortcut_mode']) {
case self::SHORTCUT_MODE_NONE:
return (int) $page['shortcut'] > 0 ? (int) $page['shortcut'] : null;
case self::SHORTCUT_MODE_FIRST_SUBPAGE:
foreach ($this->pageTree->getChildren((int) $page['uid']) as $childUid) {
if ($this->pageTree->isVisible($this->pageTree->getPage($childUid))) {
return $childUid;
}
}
return null;
case self::SHORTCUT_MODE_PARENT_PAGE:
return (int) $page['pid'] > 0 ? (int) $page['pid'] : null;
default:
// Random subpage: there is no stable URL to give.
return null;
}
}
}
@@ -36,6 +36,7 @@ use EP\EpProducts\Service\ContingentDataService;
use EP\EpProducts\Service\DateService; use EP\EpProducts\Service\DateService;
use EP\EpProducts\Service\FilterService; use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\HotelDataService; use EP\EpProducts\Service\HotelDataService;
use EP\EpProducts\Service\PickupOverviewService;
use EP\EpProducts\Service\ProductDataService; use EP\EpProducts\Service\ProductDataService;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
@@ -82,6 +83,11 @@ class ProductController extends ActionController
*/ */
private $infoboxRepository; private $infoboxRepository;
/**
* @var PickupOverviewService
*/
private $pickupOverviewService;
/** /**
* @param ProductRepository $productRepository * @param ProductRepository $productRepository
* @param ProductDataService $productDataService * @param ProductDataService $productDataService
@@ -91,6 +97,7 @@ class ProductController extends ActionController
* @param DateService $dateService * @param DateService $dateService
* @param FilterService $filterService * @param FilterService $filterService
* @param InfoboxRepository $infoboxRepository * @param InfoboxRepository $infoboxRepository
* @param PickupOverviewService $pickupOverviewService
*/ */
public function __construct( public function __construct(
ProductRepository $productRepository, ProductRepository $productRepository,
@@ -100,7 +107,8 @@ class ProductController extends ActionController
ContingentDataService $contingentDataService, ContingentDataService $contingentDataService,
DateService $dateService, DateService $dateService,
FilterService $filterService, FilterService $filterService,
InfoboxRepository $infoboxRepository InfoboxRepository $infoboxRepository,
PickupOverviewService $pickupOverviewService
) { ) {
$this->productRepository = $productRepository; $this->productRepository = $productRepository;
$this->productDataService = $productDataService; $this->productDataService = $productDataService;
@@ -110,6 +118,7 @@ class ProductController extends ActionController
$this->dateService = $dateService; $this->dateService = $dateService;
$this->filterService = $filterService; $this->filterService = $filterService;
$this->infoboxRepository = $infoboxRepository; $this->infoboxRepository = $infoboxRepository;
$this->pickupOverviewService = $pickupOverviewService;
} }
/** /**
@@ -196,6 +205,7 @@ class ProductController extends ActionController
'referringUrl' => $backlinkUrl, 'referringUrl' => $backlinkUrl,
'backlinkLabel' => $backLinkLabel, 'backlinkLabel' => $backLinkLabel,
'nonBookableInfo' => $nonBookableInfo, 'nonBookableInfo' => $nonBookableInfo,
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
]); ]);
} }
@@ -56,7 +56,7 @@ class FacebookCatalogCsvFormatter
$product['images']['image'][0], $product['images']['image'][0],
$product['link'], $product['link'],
$product['name'], $product['name'],
$minPrice . ' EUR', sprintf('%.2f EUR', $minPrice),
'brand-epreisen' 'brand-epreisen'
]; ];
} }
@@ -48,7 +48,7 @@ class GoogleBusinessFeedCsvFormatter
$product['name'], $product['name'],
$product['link'], $product['link'],
$product['images']['image'][0], $product['images']['image'][0],
$minPrice . '.00 EUR', sprintf('%.2f EUR', $minPrice),
]; ];
} }
} }
@@ -114,15 +114,20 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
*/ */
protected $skipassIncluded = false; protected $skipassIncluded = false;
/**
* @var bool
*/
protected $groupTravel = false;
/** /**
* @var bool * @var bool
*/ */
protected $busAvailable = false; protected $busAvailable = false;
/** /**
* @var int * @var float
*/ */
protected $busPrice = 0; protected $busPrice = 0.0;
/** /**
* @var string * @var string
@@ -145,19 +150,19 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
protected $pickups = ''; protected $pickups = '';
/** /**
* @var int * @var float
*/ */
protected $discount = 0; protected $discount = 0.0;
/** /**
* @var int * @var float
*/ */
protected $minPrice = 0; protected $minPrice = 0.0;
/** /**
* @var int * @var float
*/ */
protected $pseudoPrice = 0; protected $pseudoPrice = 0.0;
/** /**
* @var int * @var int
@@ -421,6 +426,22 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
$this->skipassIncluded = $skipassIncluded; $this->skipassIncluded = $skipassIncluded;
} }
/**
* @return bool
*/
public function getGroupTravel()
{
return $this->groupTravel;
}
/**
* @param bool $groupTravel
*/
public function setGroupTravel($groupTravel)
{
$this->groupTravel = $groupTravel;
}
/** /**
* @return bool * @return bool
*/ */
@@ -438,7 +459,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getBusPrice() public function getBusPrice()
{ {
@@ -446,7 +467,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $busPrice * @param float $busPrice
*/ */
public function setBusPrice($busPrice) public function setBusPrice($busPrice)
{ {
@@ -533,21 +554,21 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getDiscount() { public function getDiscount() {
return $this->discount; return $this->discount;
} }
/** /**
* @param int $discount * @param float $discount
*/ */
public function setDiscount($discount) { public function setDiscount($discount) {
$this->discount = $discount; $this->discount = $discount;
} }
/** /**
* @return int * @return float
*/ */
public function getMinPrice() public function getMinPrice()
{ {
@@ -555,7 +576,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $minPrice * @param float $minPrice
*/ */
public function setMinPrice($minPrice) public function setMinPrice($minPrice)
{ {
@@ -563,7 +584,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getPseudoPrice() public function getPseudoPrice()
{ {
@@ -571,7 +592,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $pseudoPrice * @param float $pseudoPrice
*/ */
public function setPseudoPrice($pseudoPrice) public function setPseudoPrice($pseudoPrice)
{ {
@@ -27,6 +27,7 @@ namespace EP\EpProducts\Domain\Model;
* This copyright notice MUST APPEAR in all copies of the script! * This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/ ***************************************************************/
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference; use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage; use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
@@ -1253,4 +1254,20 @@ class Hotel extends AbstractEntity implements TeaserInterface
{ {
$this->distanceLift = $distanceLift; $this->distanceLift = $distanceLift;
} }
public function getIcons(): array
{
return HotelIconUtility::getIcons([
'beds_count' => $this->bedsCount,
'parking_count' => $this->parkingCount,
'altitude' => $this->altitude,
'distance_bus' => $this->distanceBus,
'distance_lift' => $this->distanceLift,
'shower' => $this->shower,
'partyroom' => $this->partyroom,
'gamesroom' => $this->gamesroom,
'sauna' => $this->sauna,
'wifi' => $this->wifi,
]);
}
} }
@@ -3,6 +3,7 @@
namespace EP\EpProducts\Domain\Repository; namespace EP\EpProducts\Domain\Repository;
use EP\EpProducts\Domain\Model\Product; use EP\EpProducts\Domain\Model\Product;
use TYPO3\CMS\Core\Database\Connection;
class DateRepository extends AbstractRepository class DateRepository extends AbstractRepository
{ {
@@ -18,4 +19,27 @@ class DateRepository extends AbstractRepository
->execute() ->execute()
; ;
} }
/**
* @return string[] serialized pickups of each upcoming date, latest first
*/
public function findPickupsForProductAndHotel(int $productUid, int $hotelUid): array
{
$qb = $this->getDbConnection()->createQueryBuilder();
return $qb
->select('date.pickups')
->from('tx_epproducts_domain_model_date', 'date')
->where(
$qb->expr()->eq('date.product', $qb->createNamedParameter($productUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.hotel', $qb->createNamedParameter($hotelUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.group_travel', $qb->createNamedParameter(0, Connection::PARAM_INT)),
'date.date_start >= NOW()'
)
->orderBy('date.date_start', 'DESC')
->addOrderBy('date.uid', 'DESC')
->execute()
->fetchFirstColumn()
;
}
} }
@@ -210,6 +210,7 @@ class ProductRepository extends AbstractRepository
'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd', 'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd',
'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId',
'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded', 'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.season as season', 'date.product_detail_page as productDetailPage', 'date.season as season', 'date.product_detail_page as productDetailPage',
'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName', 'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName',
'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice', 'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice',
@@ -273,6 +274,7 @@ class ProductRepository extends AbstractRepository
'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights', 'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights',
'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded', 'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded',
'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded', 'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId',
'date.available as available', 'date.product_detail_page as productDetailPage', 'date.available as available', 'date.product_detail_page as productDetailPage',
) )
@@ -44,7 +44,7 @@ class Date
$date['@id'] = $array['dateBusProId']; $date['@id'] = $array['dateBusProId'];
$date['from'] = $array['dateStart']; $date['from'] = $array['dateStart'];
$date['to'] = $array['dateEnd']; $date['to'] = $array['dateEnd'];
$date['minprice'] = $array['dateMinPrice']; $date['minprice'] = (float) $array['dateMinPrice'];
$date['nights'] = $array['dateNights']; $date['nights'] = $array['dateNights'];
$date['skipassincluded'] = $array['dateSkiPassIncluded']; $date['skipassincluded'] = $array['dateSkiPassIncluded'];
$date['busincluded'] = $array['dateBusIncluded']; $date['busincluded'] = $array['dateBusIncluded'];
@@ -40,8 +40,8 @@ class Room
$room['@pax'] = $array['roomPax']; $room['@pax'] = $array['roomPax'];
$room['@code'] = $array['roomCode']; $room['@code'] = $array['roomCode'];
$room['name'] = $array['roomName']; $room['name'] = $array['roomName'];
$room['price'] = $array['roomPrice']; $room['price'] = (float) $array['roomPrice'];
$room['busprice'] = $array['busPrice']; $room['busprice'] = (float) $array['busPrice'];
$room['nights'] = $array['roomNights']; $room['nights'] = $array['roomNights'];
$room['available'] = $array['roomAvailable']; $room['available'] = $array['roomAvailable'];
@@ -0,0 +1,45 @@
<?php
namespace EP\EpProducts\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
/**
* Checks the shared key of the CMS backchannel endpoints (content feed, CMS index).
*
* An empty or missing `cmsApiKey` denies every request. ExtensionConfiguration::get() returns the
* stored configuration verbatim, so a newly added key is absent until someone saves the extension
* configuration in the backend - defaulting to "allow" there would publish the whole catalogue.
*/
class ApiKeyAuthenticator
{
/**
* @var ExtensionConfiguration
*/
private $extensionConfiguration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->extensionConfiguration = $extensionConfiguration;
}
public function isAuthorized(ServerRequestInterface $request): bool
{
$configured = (string) ($this->extensionConfiguration->get('ep_products')['cmsApiKey'] ?? '');
if ('' === $configured) {
return false;
}
// Header first: query-string keys end up in access logs. `key` stays accepted for parity
// with the existing /api/hotel and /api/product calls.
$provided = $request->getHeaderLine('X-Api-Key');
if ('' === $provided) {
$provided = (string) ($request->getQueryParams()['key'] ?? '');
}
return hash_equals($configured, $provided);
}
}
@@ -2,7 +2,10 @@
namespace EP\EpProducts\Middleware; namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use EP\EpProducts\Service\MyEpImageService; use EP\EpProducts\Service\MyEpImageService;
use EP\EpProducts\Utility\HotelIconUtility;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
@@ -40,18 +43,32 @@ class ApiMiddleware implements MiddlewareInterface
*/ */
private $imageService; private $imageService;
/**
* @var ContentFeedApi
*/
private $contentFeedApi;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
public function __construct( public function __construct(
MyEpImageService $hotelImageService, MyEpImageService $hotelImageService,
MyEpImageService $productImageService, MyEpImageService $productImageService,
MyEpImageService $regionImageService, MyEpImageService $regionImageService,
FileRepository $fileRepository, FileRepository $fileRepository,
ImageService $imageService ImageService $imageService,
ContentFeedApi $contentFeedApi,
ApiKeyAuthenticator $authenticator
) { ) {
$this->hotelImageService = $hotelImageService; $this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService; $this->productImageService = $productImageService;
$this->regionImageService = $regionImageService; $this->regionImageService = $regionImageService;
$this->fileRepository = $fileRepository; $this->fileRepository = $fileRepository;
$this->imageService = $imageService; $this->imageService = $imageService;
$this->contentFeedApi = $contentFeedApi;
$this->authenticator = $authenticator;
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -66,17 +83,17 @@ class ApiMiddleware implements MiddlewareInterface
return $this->handleProductRequest($request); return $this->handleProductRequest($request);
} }
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
return $this->contentFeedApi->handle($request);
}
return $handler->handle($request); return $handler->handle($request);
} }
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
{ {
// This is currently fake, we only check for existence. Since we are communicating if (!$this->authenticator->isAuthorized($request)) {
// on the backchannel only it's not much of a problem. return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$hotelCode = $request->getQueryParams()['hotel'] ?? null; $hotelCode = $request->getQueryParams()['hotel'] ?? null;
@@ -96,12 +113,8 @@ class ApiMiddleware implements MiddlewareInterface
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
{ {
// This is currently fake, we only check for existence. Since we are communicating if (!$this->authenticator->isAuthorized($request)) {
// on the backchannel only it's not much of a problem. return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$productCode = $request->getQueryParams()['product'] ?? null; $productCode = $request->getQueryParams()['product'] ?? null;
@@ -217,70 +230,6 @@ class ApiMiddleware implements MiddlewareInterface
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid'])); $images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
// map fields to icon ids and labels
$icons = [];
if (($hotel['beds_count'] ?? 0) > 0) {
$icons[] = [
'icon' => 'bed',
'label' => sprintf('%d Betten', $hotel['beds_count']),
];
}
if (($hotel['parking_count'] ?? 0) > 0) {
$icons[] = [
'icon' => 'parking',
'label' => sprintf('%d Parkplätze', $hotel['parking_count']),
];
}
if ($hotel['altitude'] ?? null) {
$icons[] = [
'icon' => 'mountain',
'label' => $hotel['altitude'],
];
}
if ($hotel['distance_bus'] ?? null) {
$icons[] = [
'icon' => 'bus',
'label' => $hotel['distance_bus'],
];
}
if ($hotel['distance_lift'] ?? null) {
$icons[] = [
'icon' => 'lift',
'label' => $hotel['distance_lift'],
];
}
if ($hotel['shower'] ?? null) {
$icons[] = [
'icon' => 'shower',
'label' => $hotel['shower'],
];
}
if ($hotel['partyroom'] ?? false) {
$icons[] = [
'icon' => 'party',
'label' => 'Partyzimmer',
];
}
if ($hotel['gamesroom'] ?? false) {
$icons[] = [
'icon' => 'dice',
'label' => 'Spielezimmer',
];
}
if ($hotel['sauna'] ?? false) {
$icons[] = [
'icon' => 'sauna',
'label' => 'Sauna',
];
}
if ($hotel['wifi'] ?? false) {
$icons[] = [
'icon' => 'wifi',
'label' => 'WiFi',
];
}
return [ return [
'name' => $hotel['name'], 'name' => $hotel['name'],
'code' => $hotel['code'], 'code' => $hotel['code'],
@@ -290,7 +239,7 @@ class ApiMiddleware implements MiddlewareInterface
'additional_information' => $hotel['additional_information'], 'additional_information' => $hotel['additional_information'],
'address' => $hotel['address'], 'address' => $hotel['address'],
'images' => $images, 'images' => $images,
'icons' => $icons, 'icons' => HotelIconUtility::getIcons($hotel),
'region' => $this->fetchRegionData((int) $hotel['region']), 'region' => $this->fetchRegionData((int) $hotel['region']),
]; ];
} }
@@ -0,0 +1,65 @@
<?php
namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\ContentFeed\ContentFeedConfiguration;
use EP\EpProducts\ContentFeed\ManifestBuilder;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
/**
* Keeps the chatbot content-only page type (an unstyled copy of every page) off the public web:
* only requests carrying the CMS API key get it.
*
* Runs once the page type is known and before TSFE, so a page-cache hit cannot bypass it.
* staticfilecache never stores the type (`tx_staticfilecache.disableCache`); if it did, .htaccess
* would serve the file without this check ever running.
*/
class ContentPageTypeGuardMiddleware implements MiddlewareInterface
{
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
public function __construct(ApiKeyAuthenticator $authenticator, ContentFeedConfiguration $configuration)
{
$this->authenticator = $authenticator;
$this->configuration = $configuration;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$routing = $request->getAttribute('routing');
if (!$routing instanceof PageArguments
|| (string) ManifestBuilder::CONTENT_PAGE_TYPE !== $routing->getPageType()
) {
return $handler->handle($request);
}
if (!$this->authenticator->isAuthorized($request)) {
// 404, not 403: an unauthenticated caller should not learn the type exists.
return GeneralUtility::makeInstance(ErrorController::class)
->pageNotFoundAction($request, 'The requested page does not exist');
}
if (!$this->configuration->isEnabled()) {
return ContentFeedApi::disabledResponse();
}
return $handler->handle($request);
}
}
@@ -81,7 +81,8 @@ class ApiClient implements LoggerAwareInterface
} }
try { try {
return new \DateTimeImmutable($data['timestamp']); // The API reports Europe/Berlin local time without an offset.
return new \DateTimeImmutable($data['timestamp'], new \DateTimeZone('Europe/Berlin'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage()); $message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -89,6 +90,22 @@ class ApiClient implements LoggerAwareInterface
} }
} }
/**
* One page of the product export: one row per travel date × hotel in `data`,
* and `meta` describing the whole export.
*
* @throws ApiException
*/
public function getProductExport(int $page, int $perPage): array
{
return $this->requestJson('GET', 'exports/products', [
'query' => [
'page' => $page,
'perPage' => $perPage,
],
]);
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -0,0 +1,233 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reads the product export from GET /api/exports/products on MyE&P. The rows
* already have the shape {@see DateImportService::import()} consumes, so they
* are passed on unchanged.
*
* Change detection compares GET /api/last-update against the
* `meta.sourceGeneratedAt` of the last successfully imported export, kept in
* var/transient/ep_products_import/api-imported.info. Both carry the BusPro
* export's own timestamp.
*/
final class ApiImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* The whole export is one page at current volumes; the walk does not rely on it.
*/
private const PER_PAGE = 500;
/**
* How often the walk restarts when MyE&P replaces its copy of the export
* mid-walk before giving up until the next run.
*/
private const MAX_WALKS = 3;
private const STAGING_SUBDIR = 'ep_products_import';
private const BASELINE_FILE = 'api-imported.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
/**
* @var ApiClient|null
*/
private $apiClient;
public function __construct(?ApiClient $apiClient = null)
{
$this->apiClient = $apiClient;
}
/**
* The whole export is fetched before anything is returned, so a walk that
* has to restart never leaves a stitched dataset behind.
*
* @throws ApiException
*/
public function acquire(): ?ProductImport
{
if (!$this->isNewImportAvailable()) {
return null;
}
for ($walk = 1; $walk <= self::MAX_WALKS; $walk++) {
$export = $this->walkPages();
if ($export === false) {
continue;
}
if ($export === null) {
return null;
}
[$rows, $sourceGeneratedAt] = $export;
$baseline = $this->getImportedTimestamp();
if ($baseline !== null && $sourceGeneratedAt <= $baseline) {
return null;
}
return new ProductImport($rows, $sourceGeneratedAt);
}
throw new ApiException(sprintf(
'MyE&P replaced its product export during each of %d page walks, giving up until the next run',
self::MAX_WALKS
));
}
public function markImported(ProductImport $import): void
{
$sourceGeneratedAt = $import->getSourceGeneratedAt();
if ($sourceGeneratedAt !== null) {
$file = $this->baselinePath();
GeneralUtility::mkdir_deep(dirname($file));
GeneralUtility::writeFile($file, $sourceGeneratedAt->format(\DateTimeInterface::ATOM), false);
}
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
/**
* True when MyE&P reports an export newer than the last imported one, or
* nothing has been imported yet. A missing timestamp from MyE&P means
* "not importable now", never "import".
*
* @throws ApiException
*/
public function isNewImportAvailable(): bool
{
$lastUpdateAt = $this->getApiClient()->getLastUpdateAt();
if ($lastUpdateAt === null) {
return false;
}
$baseline = $this->getImportedTimestamp();
return $baseline === null || $lastUpdateAt > $baseline;
}
/**
* `meta.sourceGeneratedAt` of the last successfully imported export.
*/
public function getImportedTimestamp(): ?\DateTimeImmutable
{
$file = $this->baselinePath();
if (!is_file($file)) {
return null;
}
return $this->parseTimestamp(trim((string) file_get_contents($file)));
}
/**
* @return array{0: array[], 1: \DateTimeImmutable}|false|null the rows and
* the export's timestamp; null when the export is not importable now;
* false when the export changed mid-walk and the walk must restart
* @throws ApiException
*/
private function walkPages()
{
$rows = [];
$sourceGeneratedAt = null;
$page = 1;
do {
$body = $this->getApiClient()->getProductExport($page, self::PER_PAGE);
$meta = $body['meta'] ?? null;
if (!is_array($meta) || !isset($body['data']) || !is_array($body['data'])) {
throw new ApiException(sprintf('MyE&P product export page %d is malformed', $page));
}
$pageGeneratedAt = $this->parseTimestamp($meta['sourceGeneratedAt'] ?? null);
if ($page === 1) {
if ($pageGeneratedAt === null) {
$this->logWarning('MyE&P product export has no source timestamp; skipping product import');
return null;
}
$sourceGeneratedAt = $pageGeneratedAt;
} elseif ($pageGeneratedAt === null || $pageGeneratedAt != $sourceGeneratedAt) {
$this->logWarning('MyE&P product export changed during the page walk; restarting', [
'page' => $page,
'expected' => $sourceGeneratedAt->format(\DateTimeInterface::ATOM),
'actual' => $meta['sourceGeneratedAt'] ?? null,
]);
return false;
}
foreach ($body['data'] as $row) {
$rows[] = $row;
}
$totalPages = (int) ($meta['totalPages'] ?? 0);
$page++;
} while ($page <= $totalPages);
$total = (int) ($meta['total'] ?? -1);
if (count($rows) !== $total) {
throw new ApiException(sprintf(
'MyE&P product export is incomplete: %d rows received, %d announced',
count($rows),
$total
));
}
if ((int) ($meta['skipped'] ?? 0) > 0) {
$this->logWarning('MyE&P skipped travel dates with unparseable dates', ['skipped' => $meta['skipped']]);
}
if ((int) ($meta['pickupsWithoutMasterData'] ?? 0) > 0) {
$this->logWarning(
'MyE&P product export has pickups without master data',
['pickupsWithoutMasterData' => $meta['pickupsWithoutMasterData']]
);
}
return [$rows, $sourceGeneratedAt];
}
/**
* @param mixed $value
*/
private function parseTimestamp($value): ?\DateTimeImmutable
{
if (!is_string($value) || $value === '') {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception $e) {
return null;
}
}
private function getApiClient(): ApiClient
{
if ($this->apiClient === null) {
$this->apiClient = GeneralUtility::makeInstance(ApiClient::class);
}
return $this->apiClient;
}
private function baselinePath(): string
{
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR . '/' . self::BASELINE_FILE;
}
private function logWarning(string $message, array $context = []): void
{
if ($this->logger !== null) {
$this->logger->warning($message, $context);
}
}
}
@@ -50,6 +50,42 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
const STATUS_ON_REQUEST = 'Anfrage'; const STATUS_ON_REQUEST = 'Anfrage';
const STATUS_STOP = 'Buchungsstop'; const STATUS_STOP = 'Buchungsstop';
/**
* BusPro ids of the selection groups whose selections make up the included
* services. Matched by id rather than by label, so a group renamed upstream
* does not silently drop out of services_included.
*/
const SELECTION_GROUP_SKIPASS = 7;
const SELECTION_GROUP_OTHER = 8;
const SELECTION_GROUP_BOARD = 71;
const SELECTION_GROUP_ACCOMMODATION = 85;
const SELECTION_GROUP_PROGRAMME = 86;
const SELECTION_GROUP_COURSES = 87;
const SELECTION_GROUP_EVENTS = 89;
const SELECTION_GROUP_TRANSPORTATION = 90;
/**
* Group 'Kunde' also carries trip types (Wochenreise, Midweekreise, ...), so
* group travel keys on the selection, not on the group's presence. Every
* date should carry either 'Gruppenkunde' or 'Endkunde'.
*/
const SELECTION_GROUP_CUSTOMER = 96;
const SELECTION_CUSTOMER_GROUP = 1418;
const SELECTION_CUSTOMER_END = 1419;
/**
* Fallback for dates the office has not tagged with either selection: these
* trip type markers in front of the travel code's trailing date mean
* end-customer travel.
*/
const END_CUSTOMER_CODE_PATTERN = '/(?:MP|WE|KT|PM|MW|FA)\d{6}$/';
/**
* 'Leistungen Personal' occurs in no export so far, so its id is unknown and
* it is still matched by label. Register its id once the warning fires.
*/
const SELECTION_GROUP_LABEL_PERSONNEL = 'Leistungen Personal';
/** /**
* @var ConfigurationManagerInterface * @var ConfigurationManagerInterface
*/ */
@@ -65,11 +101,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $hotelMappings = []; protected $hotelMappings = [];
/**
* @var array
*/
protected $pickups = [];
/** /**
* @var Connection * @var Connection
*/ */
@@ -126,18 +157,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param string $path * @param iterable<array> $rows one row per travel date × hotel, in the shape
* of GET /api/exports/products on MyE&P
* @return int * @return int
* @throws \Doctrine\DBAL\DBALException * @throws \Doctrine\DBAL\DBALException
*/ */
public function import($path): int public function import(iterable $rows): int
{ {
$this->db = $this->getDbConnection(); $this->db = $this->getDbConnection();
$this->getHotelMappings(); $this->getHotelMappings();
$this->importPickups($path);
$this->createTempTables(); $this->createTempTables();
$this->logger->info('starting product import'); $this->logger->info('starting product import');
$dateCount = $this->parseXmlFilesForProducts($path); $dateCount = $this->importProducts($rows);
$this->clearTables(); $this->clearTables();
$this->copyTables(); $this->copyTables();
$this->clearTempTables(); $this->clearTempTables();
@@ -151,50 +182,44 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return $this->importSourceFactory->get()->isNewImportAvailable(); return $this->importSourceFactory->get()->isNewImportAvailable();
} }
public function parseXmlFilesForProducts($path): int /**
* Groups the rows by product and travel date, in the order they first
* appear, because a product code can map to more than one TYPO3 product and
* each of them imports all of the product's dates.
*
* @param iterable<array> $rows
* @return int
*/
protected function importProducts(iterable $rows): int
{ {
$dateCount = 0; $products = [];
foreach ($rows as $row) {
$folder = opendir($path); $code = $row['product']['code'];
while (($fileName = readdir($folder)) !== false) { if (!isset($products[$code])) {
if (strpos($fileName, 'Ziel') === 0) { $products[$code] = ['busProId' => (int) $row['product']['busProId'], 'dates' => []];
$filePath = $path . '/' . $fileName;
$dateCount += $this->parseProductXmlFile($filePath);
} }
$products[$code]['dates'][(int) $row['date']['busProId']][] = $row;
}
$dateCount = 0;
foreach ($products as $code => $product) {
$dateCount += $this->importProduct((string) $code, $product['busProId'], $product['dates']);
} }
return $dateCount; return $dateCount;
} }
/** /**
* @param string $file * Recreated on every run, so the temp tables always follow the schema of the
* @return int * live tables (a column type change would otherwise survive in them) and a
* crashed import cannot leave rows behind for the next one.
*/ */
public function parseProductXmlFile($file): int
{
if (!file_exists($file)) {
$this->logger->error('product xml not found', ['file' => $file]);
return 0;
}
libxml_use_internal_errors(true);
/** @var \SimpleXMLElement $xmlData */
if (!$xmlData = simplexml_load_string(file_get_contents($file))) {
libxml_clear_errors();
return 0;
}
if ($xmlData->reise[0] === null) {
return 0;
}
return $this->parseProduct($xmlData->reise[0]);
}
public function createTempTables(): void public function createTempTables(): void
{ {
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date'); $this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_date_temp');
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room'); $this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_room_temp');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
} }
public function clearTables(): void public function clearTables(): void
@@ -216,13 +241,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlProduct * @param string $code
* @param int $busProId
* @param array[][] $dates rows grouped by travel date
* @return int * @return int
*/ */
protected function parseProduct(\SimpleXMLElement $xmlProduct): int protected function importProduct(string $code, int $busProId, array $dates): int
{ {
$code = (string) $xmlProduct->attributes()['code'];
$busProId = (int) $xmlProduct->attributes()['idbuspro'];
$dateCount = 0; $dateCount = 0;
$sql = ' $sql = '
@@ -260,8 +285,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$product['bus_pro_id'] = $busProId; $product['bus_pro_id'] = $busProId;
$product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']); $product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']);
$product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']); $product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']);
foreach ($xmlProduct->termin as $xmlDate) { foreach ($dates as $rows) {
$dateCount += $this->parseDates($xmlDate, $product); $dateCount += $this->importDate($rows, $product);
} }
} }
@@ -269,25 +294,21 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlDate * @param array[] $rows one per hotel of the travel date
* @param array $product * @param array $product
* @return int * @return int
*/ */
protected function parseDates(\SimpleXMLElement $xmlDate, array $product): int protected function importDate(array $rows, array $product): int
{ {
$dateCount = 0; $dateCount = 0;
// $earliestDate = new \DateTimeImmutable('next year');
foreach ($xmlDate->hotel as $xmlHotel) { // Selection groups are date-level, so any hotel row tells.
// don't import dates starting later than one year from now $this->checkCustomerSelection(reset($rows));
// $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']);
// if ($dateStart > $earliestDate) {
// continue;
// }
$date = $this->createDateRecord($product, $xmlDate); foreach ($rows as $row) {
$date = $this->createDateRecord($product, $row);
$hotelCode = (string) $xmlHotel->attributes()['code']; $hotelCode = $row['hotel']['code'];
$key = $hotelCode . $date['date_start']; $key = $hotelCode . $date['date_start'];
// Import daytrip dates only once per hotel as room information is taken // Import daytrip dates only once per hotel as room information is taken
@@ -296,7 +317,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue; continue;
} }
if ($this->parseHotel($xmlHotel, $date) !== false) { if ($this->parseHotel($row, $date) !== false) {
$this->db->insert( $this->db->insert(
'tx_epproducts_domain_model_date_temp', 'tx_epproducts_domain_model_date_temp',
$date $date
@@ -322,17 +343,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $product * @param array $product
* @param \SimpleXMLElement $xmlDate * @param array $row
* @return array * @return array
*/ */
protected function createDateRecord(array $product, \SimpleXMLElement $xmlDate): array protected function createDateRecord(array $product, array $row): array
{ {
$dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']); $dateStart = \DateTime::createFromFormat('Y-m-d', $row['date']['from']);
$dateEnd = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['bis']); $dateEnd = \DateTime::createFromFormat('Y-m-d', $row['date']['to']);
$nights = $dateStart->diff($dateEnd)->format('%d'); $nights = $dateStart->diff($dateEnd)->format('%d');
$busProId = (int) $xmlDate->attributes()['idbuspro']; $busProId = (int) $row['date']['busProId'];
$code = (string) $xmlDate->attributes()['code']; $code = (string) $row['date']['code'];
$date = [ $date = [
'pid' => $this->pid, 'pid' => $this->pid,
@@ -375,15 +396,15 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'important' => $product['important'], 'important' => $product['important'],
]; ];
$includedAccommodation = $this->parseSelectionGroup($xmlDate, 'Leistungen Übernachtung'); $includedAccommodation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_ACCOMMODATION);
$includedSkiPass = $this->parseSelectionGroup($xmlDate, 'Leistungen Skipass'); $includedSkiPass = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_SKIPASS);
$includedBoard = $this->parseSelectionGroup($xmlDate, 'Leistungen Verpflegung'); $includedBoard = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_BOARD);
$includedTransportation = $this->parseSelectionGroup($xmlDate, 'Leistungen Transport'); $includedTransportation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_TRANSPORTATION);
$includedProgramme = $this->parseSelectionGroup($xmlDate, 'Leistungen Programm'); $includedProgramme = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_PROGRAMME);
$includedPersonnel = $this->parseSelectionGroup($xmlDate, 'Leistungen Personal'); $includedPersonnel = $this->parsePersonnelSelectionGroup($row['selectionGroups']);
$includedEvents = $this->parseSelectionGroup($xmlDate, 'Leistungen Events'); $includedEvents = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_EVENTS);
$includedOther = $this->parseSelectionGroup($xmlDate, 'Leistungen Sonstige'); $includedOther = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_OTHER);
$includedCourses = $this->parseSelectionGroup($xmlDate, 'Leistungen Kurse'); $includedCourses = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_COURSES);
$includedServices = [ $includedServices = [
...$includedAccommodation, ...$includedAccommodation,
@@ -408,7 +429,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$skiPassIncluded = (int) (count($includedSkiPass) > 0); $skiPassIncluded = (int) (count($includedSkiPass) > 0);
$date['skipass_included'] = $skiPassIncluded; $date['skipass_included'] = $skiPassIncluded;
$servicesTransportation = $this->parseService($xmlDate, 'lei_befoerderung'); $date['group_travel'] = (int) $this->isGroupTravel($row);
$servicesTransportation = $this->parseService($row['transportServices']);
$discount = $this->determineDiscount($servicesTransportation); $discount = $this->determineDiscount($servicesTransportation);
$date['discount'] = $discount; $date['discount'] = $discount;
@@ -430,11 +453,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
if ($discount) { if ($discount) {
$servicesTransportationOptional[] = [ $servicesTransportationOptional[] = [
'label' => 'Rabatt bei Eigenanreise', 'label' => 'Rabatt bei Eigenanreise',
'price' => $discount, 'price' => number_format($discount, 2, ',', '.'),
]; ];
} }
$servicesGeneral = $this->parseService($xmlDate, 'lei_sonstiges'); $servicesGeneral = $this->parseService($row['dateServices']);
$combinedServices = []; $combinedServices = [];
@@ -456,29 +479,22 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$date['services_general'] = serialize($combinedServices); $date['services_general'] = serialize($combinedServices);
$pickups = []; $pickups = [];
foreach ($xmlDate->xpath('zustiege/zustieg') as $pickup) { foreach ($row['pickups'] as $pickup) {
$attributes = $pickup->attributes(); $busProId = (int) $pickup['busProId'];
if (null === $attributes) {
continue;
}
$busProId = (int) $attributes['idbuspro'];
if ($busProId === 0) { if ($busProId === 0) {
continue; continue;
} }
$timestamp = (string) $attributes['zeit'];
$parts = explode(' ', $timestamp);
if (2 === count($parts)) {
[$pickupDate, $timePart] = $parts;
$pickupTime = substr($timePart, 0, 5);
} else {
$pickupDate = $dateStart->format('d.m.Y'); $pickupDate = $dateStart->format('d.m.Y');
$pickupTime = $parts[0]; $pickupTime = '';
if ($pickup['datetime'] !== null) {
[$pickupDate, $pickupTime] = $this->parsePickupDateTime($pickup['datetime']);
} }
$pickups[] = [ $pickups[] = [
'bus_pro_id' => $busProId, 'bus_pro_id' => $busProId,
'city' => $this->pickups[$busProId]['city'], 'city' => $pickup['city'],
'street' => $this->pickups[$busProId]['street'], 'street' => $pickup['street'],
'price' => (float) str_replace(',', '.', (string) $attributes['preis']), // A pickup without a stated price has always been shown as included.
'price' => $pickup['price'] === null ? 0.0 : (float) $pickup['price'],
'date' => $pickupDate, 'date' => $pickupDate,
'time' => $pickupTime, 'time' => $pickupTime,
]; ];
@@ -495,21 +511,20 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}); });
$date['pickups'] = serialize($pickups); $date['pickups'] = serialize($pickups);
$guide = $this->parseGuide($xmlDate); $date['guide'] = serialize($row['guide'] ?? []);
$date['guide'] = serialize($guide);
return $date; return $date;
} }
/** /**
* @param \SimpleXMLElement $xmlHotel * @param array $row
* @param array $date * @param array $date
* @return bool * @return bool
*/ */
protected function parseHotel(\SimpleXMLElement $xmlHotel, array &$date): bool protected function parseHotel(array $row, array &$date): bool
{ {
$code = (string) $xmlHotel->attributes()['code']; $code = $row['hotel']['code'];
$busProId = (string) $xmlHotel->attributes()['idbuspro']; $busProId = (int) $row['hotel']['busProId'];
if (!array_key_exists($code, $this->hotelMappings)) { if (!array_key_exists($code, $this->hotelMappings)) {
$this->logger->warning('hotel not found', ['code' => $code]); $this->logger->warning('hotel not found', ['code' => $code]);
@@ -522,7 +537,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services = []; $services = [];
$generalServices = unserialize($date['services_general']); $generalServices = unserialize($date['services_general']);
$hotelServices = $this->parseService($xmlHotel, 'lei_sonstiges', true); $hotelServices = $this->parseService($row['hotelServices'], true);
$this->addServices($generalServices, $services); $this->addServices($generalServices, $services);
$this->addServices($hotelServices, $services); $this->addServices($hotelServices, $services);
foreach ($services as $key => $list) { foreach ($services as $key => $list) {
@@ -532,7 +547,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services[$key] = $list; $services[$key] = $list;
} }
$this->parseRooms($xmlHotel, $hotel['uid'], $date, $services); $this->parseRooms($row, $hotel['uid'], $date, $services);
$date['hotel'] = $hotel['uid']; $date['hotel'] = $hotel['uid'];
$date['hotel_code'] = $hotel['code']; $date['hotel_code'] = $hotel['code'];
@@ -551,57 +566,33 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return true; return true;
} }
protected function parseGuide(\SimpleXMLElement $xmlDate)
{
$transportationServicesBus = $xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]');
foreach ($transportationServicesBus as $service) {
if ('RUECK' === (string) $service->richtung) {
continue;
}
if (false === isset($service->zustiegsplanung->reiseleiter)) {
continue;
}
return [
'name' => (string) $service->zustiegsplanung->reiseleiter->name,
'phone' => (string) $service->zustiegsplanung->reiseleiter->telefon,
];
}
return [];
}
/** /**
* @param \SimpleXMLElement $xmlHotel * @param array $row
* @param int $hotelUid * @param int $hotelUid
* @param array $date * @param array $date
* @param array $services * @param array $services
*/ */
protected function parseRooms(\SimpleXMLElement $xmlHotel, $hotelUid, array &$date, array $services): void protected function parseRooms(array $row, $hotelUid, array &$date, array $services): void
{ {
$minPriceOverall = 0; $minPriceOverall = 0.0;
$minPriceAvailable = 0; $minPriceAvailable = 0.0;
$minPax = 0; $minPax = 0;
$availableTotal = 0; $availableTotal = 0;
$boardCode = ''; $boardCode = '';
$count = 0; $count = 0;
foreach ($xmlHotel->xpath('zimmer/preis') as $roomXml) { if ($row['pseudoPrice'] !== null) {
$code = trim((string) $roomXml->attributes()['zimmercode']); $date['pseudo_price'] = (float) $row['pseudoPrice'];
if ($code === static::CODE_PSEUDOPRICE) {
$price = (int) $roomXml->attributes()['preis'];
$date['pseudo_price'] = $price;
continue;
} }
$label = (string) $roomXml->attributes()['zimmertext']; foreach ($row['rooms'] as $roomRow) {
$roomMinPax = (int) $roomXml->attributes()['MinPax']; $code = $roomRow['code'];
$pax = (int) $roomXml->attributes()['MaxPax']; $label = $roomRow['label'];
$roomMinPax = (int) $roomRow['minPax'];
$pax = (int) $roomRow['maxPax'];
$available = (int) $roomXml->attributes()['verfuegbar']; $available = (int) $roomRow['available'];
$price = (int) $roomXml->attributes()['preis']; $price = (float) $roomRow['price'];
// Skip rooms with zero price _and_ zero availability. // Skip rooms with zero price _and_ zero availability.
if (!$available && !$price) { if (!$available && !$price) {
@@ -628,38 +619,38 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$availableTotal += $available * $pax; $availableTotal += $available * $pax;
// Determine lowest price of available rooms // Determine lowest price of available rooms
if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0 || $price < $minPriceAvailable)) { if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0.0 || $price < $minPriceAvailable)) {
$minPriceAvailable = $price; $minPriceAvailable = $price;
} }
// Determine lowest overall price // Determine lowest overall price
if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0 || $price < $minPriceOverall)) { if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0.0 || $price < $minPriceOverall)) {
$minPriceOverall = $price; $minPriceOverall = $price;
} }
// Determine status // Determine status
$statusXml = (string) $roomXml->attributes()['status']; $sourceStatus = $roomRow['status'];
if (static::STATUS_STOP === $statusXml && $price) { if (static::STATUS_STOP === $sourceStatus && $price) {
$status = Room::ROOM_STATUS_FULLY_BOOKED; $status = Room::ROOM_STATUS_FULLY_BOOKED;
} elseif (static::STATUS_ON_REQUEST === $statusXml) { } elseif (static::STATUS_ON_REQUEST === $sourceStatus) {
$status = Room::ROOM_STATUS_ON_REQUEST; $status = Room::ROOM_STATUS_ON_REQUEST;
} else { } else {
$status = Room::ROOM_STATUS_AVAILABLE; $status = Room::ROOM_STATUS_AVAILABLE;
} }
$boardCode = (string) $roomXml->attributes()['vpcode']; $boardCode = $roomRow['boardCode'];
$room = [ $room = [
'pid' => $this->pid, 'pid' => $this->pid,
'date' => 0, 'date' => 0,
'hotel' => $hotelUid, 'hotel' => $hotelUid,
'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'], 'bus_pro_id' => (int) $roomRow['busProId'],
'code' => $code, 'code' => $code,
'type' => $roomType, 'type' => $roomType,
'name' => $label, 'name' => $label,
'pax' => $pax, 'pax' => $pax,
'nights' => (int) $roomXml->attributes()['naechte'], 'nights' => (int) $roomRow['nights'],
'price' => $price, 'price' => $price,
'available' => $available * $pax, 'available' => $available * $pax,
'status' => $status, 'status' => $status,
@@ -683,45 +674,43 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $services * @param array $services
* @return int * @return float
*/ */
protected function determineDiscount(array $services): int protected function determineDiscount(array $services): float
{ {
foreach ($services as $key => $section) { foreach ($services as $key => $section) {
foreach ($section as $service) { foreach ($section as $service) {
if ($service['price'] < 0) { $price = $this->parseFormattedPrice($service['price']);
return (int) $service['price']; if ($price < 0) {
return $price;
} }
} }
} }
return 0; return 0.0;
} }
/** /**
* @param \SimpleXMLElement $xmlData * @param array[] $services a service list of a row
* @param string $serviceLabel
* @param bool $skipWithoutPrice * @param bool $skipWithoutPrice
* @return array * @return array
*/ */
protected function parseService(\SimpleXMLElement $xmlData, string $serviceLabel, bool $skipWithoutPrice = false): array protected function parseService(array $services, bool $skipWithoutPrice = false): array
{ {
$data = []; $data = [];
$services = $xmlData->xpath($serviceLabel . '/leistung');
foreach ($services as $service) { foreach ($services as $service) {
// Skip transportation service direction 'back' // Skip transportation service direction 'back'
if ($service->richtung && (string) $service->richtung === 'RUECK') { if ($service['direction'] === 'RUECK') {
continue; continue;
} }
// Skip services with status 'Buchungsstop' // Skip services with status 'Buchungsstop'
$status = (string) $service->status; if ($service['status'] === static::STATUS_STOP) {
if ($status === 'Buchungsstop') {
continue; continue;
} }
// Skip services of subcategory 'EIN' // Skip services of subcategory 'EIN'
$type = (string) $service->attributes()['unterart']; $type = $service['subType'];
if ($type === 'EIN') { if ($type === 'EIN') {
continue; continue;
} }
@@ -731,13 +720,14 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$type = 'VER'; $type = 'VER';
} }
$price = (float) str_replace(',', '.', $service->preis); // A service without a stated price has always been shown as included.
$price = $service['price'] === null ? 0.0 : (float) $service['price'];
if ($skipWithoutPrice && $price === 0.0) { if ($skipWithoutPrice && $price === 0.0) {
continue; continue;
} }
$label = (string) $service->text; $label = $service['label'];
if ($type === 'BUS') { if ($type === 'BUS') {
$label = static::LABEL_BUS; $label = static::LABEL_BUS;
} elseif ($type === 'BAH') { } elseif ($type === 'BAH') {
@@ -748,8 +738,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$data[$type] = []; $data[$type] = [];
} }
$data[$type][(int)$service->attributes()['id']] = [ $data[$type][(int) $service['busProId']] = [
'idBusPro' => (int) $service->attributes()['idbuspro'], 'idBusPro' => (int) $service['busProId'],
'label' => $label, 'label' => $label,
'price' => number_format($price, 2, ',', '.'), 'price' => number_format($price, 2, ',', '.'),
]; ];
@@ -761,21 +751,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlDate * @param array[] $selectionGroups
* @param string $label * @param int $busProId
* @return array * @return array
*/ */
protected function parseSelectionGroup(\SimpleXMLElement $xmlDate, string $label): array protected function parseSelectionGroup(array $selectionGroups, int $busProId): array
{ {
$data = []; $data = [];
$selectionGroup = $xmlDate->xpath('selektiongruppe[@bezeichnung="' . $label . '"]/selektion'); foreach ($selectionGroups as $group) {
foreach ($selectionGroup as $selection) { if ((int) $group['busProId'] === $busProId) {
$attributes = $selection->attributes(); $this->addSelections($group['selections'], $data);
if (null === $attributes) {
continue;
} }
$id = (int) $attributes['idbuspro'];
$data[$id] = (string) $attributes['bezeichnung'];
} }
ksort($data); ksort($data);
@@ -783,6 +769,83 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return $data; return $data;
} }
/**
* @param array[] $selectionGroups
* @return array
*/
protected function parsePersonnelSelectionGroup(array $selectionGroups): array
{
$data = [];
foreach ($selectionGroups as $group) {
if ($group['label'] !== static::SELECTION_GROUP_LABEL_PERSONNEL) {
continue;
}
$this->logger->warning(
'selection group matched by label, register its id',
['label' => $group['label'], 'idbuspro' => (int) $group['busProId']]
);
$this->addSelections($group['selections'], $data);
}
ksort($data);
return $data;
}
/**
* 'Gruppenkunde' and 'Endkunde' decide. A date carrying neither is judged by
* its travel code.
*
* @param array $row
* @return bool
*/
protected function isGroupTravel(array $row): bool
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup || $isEnd) {
return $isGroup;
}
return !preg_match(static::END_CUSTOMER_CODE_PATTERN, (string) $row['date']['code']);
}
/**
* Reports dates whose customer selection is missing or contradictory, along
* with what they import as.
*
* @param array $row
*/
protected function checkCustomerSelection(array $row): void
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup !== $isEnd) {
return;
}
$this->logger->warning(
$isGroup
? 'date has both Gruppenkunde and Endkunde selected, importing as group travel'
: 'date has neither Gruppenkunde nor Endkunde selected, falling back to travel code, importing as '
. ($this->isGroupTravel($row) ? 'group travel' : 'end-customer travel'),
['product' => $row['product']['code'], 'date' => $row['date']['code']]
);
}
/**
* @param array[] $selections
* @param array $data
*/
protected function addSelections(array $selections, array &$data): void
{
foreach ($selections as $selection) {
$data[(int) $selection['busProId']] = $selection['label'];
}
}
/** /**
* @param array $services * @param array $services
* @param array $date * @param array $date
@@ -794,12 +857,44 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
foreach ($services['BUS'] as $service) { foreach ($services['BUS'] as $service) {
$price = $this->parseFormattedPrice($service['price']);
$date['bus_available'] = 1; $date['bus_available'] = 1;
$date['bus_price'] = (int) $service['price']; $date['bus_price'] = $price;
$date['bus_included'] = (int) ((int) $service['price'] === 0); $date['bus_included'] = (int) ($price === 0.0);
} }
} }
/**
* A pickup time is the local date and time as BusPro states it
* ("2027-03-28T02:45:00"), taken over as is. An ISO-8601 time with an
* offset is still accepted and converted to Europe/Berlin, which cannot
* express a time inside the daylight saving gap.
*
* @param string $datetime
* @return string[] date as d.m.Y and time as H:i
*/
protected function parsePickupDateTime(string $datetime): array
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/', $datetime, $parts)) {
return [$parts[3] . '.' . $parts[2] . '.' . $parts[1], $parts[4] . ':' . $parts[5]];
}
$pickupDateTime = (new \DateTimeImmutable($datetime))->setTimezone(new \DateTimeZone('Europe/Berlin'));
return [$pickupDateTime->format('d.m.Y'), $pickupDateTime->format('H:i')];
}
/**
* Reads back a price parseService() formatted for display ("1.234,50").
*
* @param string $price
* @return float
*/
protected function parseFormattedPrice(string $price): float
{
return (float) str_replace(['.', ','], ['', '.'], $price);
}
protected function getHotelMappings(): void protected function getHotelMappings(): void
{ {
$sql = ' $sql = '
@@ -813,7 +908,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0 WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0
UNION SELECT matchcode.code code, matchcode.entity uid UNION SELECT matchcode.code code, matchcode.entity uid
FROM tx_epproducts_domain_model_matchcode matchcode FROM tx_epproducts_domain_model_matchcode matchcode
INNER JOIN tx_epproducts_domain_model_hotel matchcode_hotel ON matchcode.entity = matchcode_hotel.uid
WHERE matchcode.deleted = 0 AND matchcode.hidden = 0 WHERE matchcode.deleted = 0 AND matchcode.hidden = 0
AND matchcode_hotel.deleted = 0 AND matchcode_hotel.hidden = 0
ORDER BY code ORDER BY code
) u ) u
LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid
@@ -870,27 +967,4 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
} }
protected function importPickups(string $path): void
{
libxml_use_internal_errors (true);
$file = $path . '/zustiege.xml';
if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) {
libxml_clear_errors();
$this->logger->warning('pickup xml missing or invalid', ['file' => $file]);
return;
}
foreach ($xmlData->zustieg as $entry) {
$attributes = $entry->attributes();
if (null === $attributes) {
continue;
}
$idBusPro = (int) $attributes['idbuspro'];
$this->pickups[$idBusPro] = [
'city' => (string) $entry->ort,
'street' => (string) $entry->strasse,
];
}
}
} }
@@ -230,6 +230,7 @@ class DateService implements SingletonInterface
'summer' => $row['season'] === 's', 'summer' => $row['season'] === 's',
'busIncluded' => (bool) $row['dateBusIncluded'], 'busIncluded' => (bool) $row['dateBusIncluded'],
'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'], 'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'],
'groupTravel' => (bool) $row['dateGroupTravel'],
'roomUid' => $row['roomUid'], 'roomUid' => $row['roomUid'],
'roomName' => $row['roomName'], 'roomName' => $row['roomName'],
'available' => $row['roomAvailable'] && $row['roomPrice'], 'available' => $row['roomAvailable'] && $row['roomPrice'],
@@ -320,6 +321,7 @@ class DateService implements SingletonInterface
'nights' => $row['dateNights'], 'nights' => $row['dateNights'],
'busIncluded' => (bool) $row['dateBusIncluded'], 'busIncluded' => (bool) $row['dateBusIncluded'],
'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'], 'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'],
'groupTravel' => (bool) $row['dateGroupTravel'],
'servicesIncluded' => unserialize($row['dateServicesIncluded'], ['allowed_classes' => false]), 'servicesIncluded' => unserialize($row['dateServicesIncluded'], ['allowed_classes' => false]),
'servicesGeneral' => unserialize($row['dateServicesGeneral'], ['allowed_classes' => false]), 'servicesGeneral' => unserialize($row['dateServicesGeneral'], ['allowed_classes' => false]),
'season' => $row['season'], 'season' => $row['season'],
@@ -2,6 +2,7 @@
namespace EP\EpProducts\Service; namespace EP\EpProducts\Service;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -17,16 +18,22 @@ final class LocalImportSource implements ProductImportSource, SingletonInterface
private const UPLOAD_MARKER = 'fileadmin/xmlexport/uebertragung.info'; private const UPLOAD_MARKER = 'fileadmin/xmlexport/uebertragung.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info'; private const IMPORT_MARKER = 'fileadmin/products_import.info';
public function acquire(): ?string public function acquire(): ?ProductImport
{ {
if (!$this->isNewImportAvailable()) { if (!$this->isNewImportAvailable()) {
return null; return null;
} }
return GeneralUtility::getFileAbsFileName(self::XML_DIR); $path = GeneralUtility::getFileAbsFileName(self::XML_DIR);
$reader = new XmlProductReader(
$path,
GeneralUtility::makeInstance(LogManager::class)->getLogger(XmlProductReader::class)
);
return new ProductImport($reader, $reader->getSourceGeneratedAt(), $path);
} }
public function markImported(string $path): void public function markImported(ProductImport $import): void
{ {
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER); (new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
} }
@@ -0,0 +1,82 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\Domain\Model\Product;
use EP\EpProducts\Domain\Repository\DateRepository;
/**
* Lists the pickup points of a product's latest upcoming end-customer date that
* has any, with that date's time and round-trip surcharge per pickup point.
* Group travel dates are left out: their pickups are arranged per group and
* are not offered publicly. Pickups, times and prices may differ on other
* dates; taking the latest one keeps the values unique and shows them for as
* long as the product is offered.
*
* The returned shape is meant to be served by MyE&P later on, so that only the
* data source has to change once the local date table goes away.
*/
class PickupOverviewService
{
/**
* @var DateRepository
*/
private $dateRepository;
public function __construct(DateRepository $dateRepository)
{
$this->dateRepository = $dateRepository;
}
/**
* @return array{pickups: array<int, array{busProId: int, city: string, street: string, price: float, time: string}>}
*/
public function getOverview(Product $product, ?Hotel $hotel): array
{
$overview = ['pickups' => []];
if ($hotel === null || $product->isDaytrip()) {
return $overview;
}
$dates = $this->dateRepository->findPickupsForProductAndHotel((int) $product->getUid(), (int) $hotel->getUid());
foreach ($dates as $serializedPickups) {
$pickups = $this->readPickups((string) $serializedPickups);
if ($pickups !== []) {
$overview['pickups'] = $pickups;
break;
}
}
return $overview;
}
/**
* Keeps the order of the import, which is the boarding order.
*/
private function readPickups(string $serializedPickups): array
{
$datePickups = @unserialize($serializedPickups, ['allowed_classes' => false]);
if (!is_array($datePickups)) {
return [];
}
$pickups = [];
foreach ($datePickups as $pickup) {
$busProId = (int) ($pickup['bus_pro_id'] ?? 0);
if ($busProId === 0 || isset($pickups[$busProId])) {
continue;
}
$pickups[$busProId] = [
'busProId' => $busProId,
'city' => (string) ($pickup['city'] ?? ''),
'street' => (string) ($pickup['street'] ?? ''),
// A pickup's price is one leg; the surcharge is split evenly between out- and inbound.
'price' => 2 * (float) ($pickup['price'] ?? 0.0),
'time' => (string) ($pickup['time'] ?? ''),
];
}
return array_values($pickups);
}
}
@@ -0,0 +1,56 @@
<?php
namespace EP\EpProducts\Service;
/**
* One acquired product export: the rows to hand to
* {@see DateImportService::import()}, the source's own timestamp, and whatever
* the source needs to find it again in {@see ProductImportSource::markImported()}.
*/
final class ProductImport
{
/**
* @var iterable<array>
*/
private $rows;
/**
* @var \DateTimeImmutable|null
*/
private $sourceGeneratedAt;
/**
* @var string|null
*/
private $location;
/**
* @param iterable<array> $rows
* @param \DateTimeImmutable|null $sourceGeneratedAt
* @param string|null $location local directory of an XML export, null otherwise
*/
public function __construct(iterable $rows, ?\DateTimeImmutable $sourceGeneratedAt, ?string $location = null)
{
$this->rows = $rows;
$this->sourceGeneratedAt = $sourceGeneratedAt;
$this->location = $location;
}
/**
* @return iterable<array>
*/
public function getRows(): iterable
{
return $this->rows;
}
public function getSourceGeneratedAt(): ?\DateTimeImmutable
{
return $this->sourceGeneratedAt;
}
public function getLocation(): ?string
{
return $this->location;
}
}
@@ -3,25 +3,26 @@
namespace EP\EpProducts\Service; namespace EP\EpProducts\Service;
/** /**
* A source of the busProNet product export (Ziel_*.xml, zustiege.xml, * A source of the BusPro product export. Implementations read the XML export
* uebertragung.info). Implementations either read a local drop directory or * from a local drop directory or a remote SFTP server, or the JSON export from
* pull the files from a remote SFTP server; the caller does not care which. * the MyE&P API; the caller does not care which.
*/ */
interface ProductImportSource interface ProductImportSource
{ {
/** /**
* Returns a local directory ready to hand to {@see DateImportService::import()}, * Returns the export ready to hand to {@see DateImportService::import()},
* or null when there is nothing newer than the last successful import. * or null when there is nothing newer than the last successful import.
* *
* @throws SftpImportException on an unrecoverable acquisition failure * @throws SftpImportException on an unrecoverable SFTP acquisition failure
* @throws \EP\EpProducts\MyEP\ApiException on an unrecoverable API failure
*/ */
public function acquire(): ?string; public function acquire(): ?ProductImport;
/** /**
* Called once {@see DateImportService::import()} of the acquired path has * Called once {@see DateImportService::import()} of the acquired export has
* succeeded, so the source can advance its "last imported" bookkeeping. * succeeded, so the source can advance its "last imported" bookkeeping.
*/ */
public function markImported(string $path): void; public function markImported(ProductImport $import): void;
/** /**
* Best-effort, side-effect-free check for whether a newer export is available. * Best-effort, side-effect-free check for whether a newer export is available.
@@ -9,18 +9,24 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Picks the product import source based on the `productImportSource` extension * Picks the product import source based on the `productImportSource` extension
* configuration switch: `sftp` (default) pulls from the remote SFTP server, * configuration switch: `sftp` (default) pulls from the remote SFTP server,
* `local` reads the legacy fileadmin/xmlexport/ drop directory. * `local` reads the legacy fileadmin/xmlexport/ drop directory, `api` reads the
* product export of the MyE&P API.
*/ */
final class ProductImportSourceFactory implements SingletonInterface final class ProductImportSourceFactory implements SingletonInterface
{ {
public const MODE_SFTP = 'sftp'; public const MODE_SFTP = 'sftp';
public const MODE_LOCAL = 'local'; public const MODE_LOCAL = 'local';
public const MODE_API = 'api';
public function get(): ProductImportSource public function get(): ProductImportSource
{ {
if ($this->getMode() === self::MODE_LOCAL) { $mode = $this->getMode();
if ($mode === self::MODE_LOCAL) {
return GeneralUtility::makeInstance(LocalImportSource::class); return GeneralUtility::makeInstance(LocalImportSource::class);
} }
if ($mode === self::MODE_API) {
return GeneralUtility::makeInstance(ApiImportSource::class);
}
return GeneralUtility::makeInstance(SftpImportSource::class); return GeneralUtility::makeInstance(SftpImportSource::class);
} }
@@ -30,6 +36,6 @@ final class ProductImportSourceFactory implements SingletonInterface
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products'); $config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
$mode = strtolower(trim((string)(is_array($config) ? ($config['productImportSource'] ?? '') : ''))); $mode = strtolower(trim((string)(is_array($config) ? ($config['productImportSource'] ?? '') : '')));
return $mode === self::MODE_LOCAL ? self::MODE_LOCAL : self::MODE_SFTP; return in_array($mode, [self::MODE_LOCAL, self::MODE_API], true) ? $mode : self::MODE_SFTP;
} }
} }
@@ -12,8 +12,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Pulls the busProNet product export (Ziel_*.xml, zustiege.xml, uebertragung.info) * Pulls the busProNet product export (Ziel_*.xml, zustiege.xml, uebertragung.info)
* from a remote SFTP server into a local staging directory so the existing * from a remote SFTP server into a local staging directory and reads it through
* {@see DateImportService::import()} can keep operating on a plain local folder. * an {@see XmlProductReader}.
* *
* Change detection compares the remote uebertragung.info timestamp against the * Change detection compares the remote uebertragung.info timestamp against the
* staged copy of the last successfully imported export * staged copy of the last successfully imported export
@@ -42,11 +42,19 @@ final class SftpImportSource implements ProductImportSource, SingletonInterface,
private $configuration; private $configuration;
/** /**
* {@see ProductImportSource::acquire()} — alias for {@see fetchIfNewer()}. * {@see ProductImportSource::acquire()} — {@see fetchIfNewer()}, read through
* an {@see XmlProductReader}.
*/ */
public function acquire(): ?string public function acquire(): ?ProductImport
{ {
return $this->fetchIfNewer(); $path = $this->fetchIfNewer();
if ($path === null) {
return null;
}
$reader = new XmlProductReader($path, $this->logger);
return new ProductImport($reader, $reader->getSourceGeneratedAt(), $path);
} }
/** /**
@@ -54,9 +62,9 @@ final class SftpImportSource implements ProductImportSource, SingletonInterface,
* change-detection baseline (only reached after a successful import), then * change-detection baseline (only reached after a successful import), then
* refresh the display-only marker for the BE module and system-info toolbar. * refresh the display-only marker for the BE module and system-info toolbar.
*/ */
public function markImported(string $path): void public function markImported(ProductImport $import): void
{ {
$this->commit($path); $this->commit((string)$import->getLocation());
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER); (new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
} }
@@ -0,0 +1,369 @@
<?php
namespace EP\EpProducts\Service;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Reads a BusPro XML export directory (Ziel_*.xml, zustiege.xml) and yields one
* row per travel date × hotel, in exactly the shape of the rows served by
* GET /api/exports/products on MyE&P, so {@see DateImportService::import()}
* does not care which of the two it is fed.
*
* Nothing is filtered or rewritten here: every display rule stays in
* DateImportService. Prices are parsed from the German format, and a price the
* source does not state is null. Like the API, service labels and pickup
* cities and streets are trimmed with inner whitespace collapsed; every other
* text is passed on verbatim.
*/
final class XmlProductReader implements \IteratorAggregate
{
private const PRODUCT_PREFIX = 'Ziel';
private const PICKUPS_FILE = 'zustiege.xml';
private const MARKER_FILE = 'uebertragung.info';
private const MARKER_TS_FORMAT = 'd.m.Y H:i:s';
private const CODE_PSEUDOPRICE = 'PDGS';
private const TIMEZONE = 'Europe/Berlin';
private const LOCAL_DATETIME_FORMAT = 'Y-m-d\TH:i:s';
/**
* @var string
*/
private $path;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var \DateTimeZone
*/
private $timezone;
/**
* Pickup times are clock readings, not instants. They are handled in UTC,
* which has no daylight saving gaps, so a time BusPro plans inside one
* (02:45 on the night clocks go forward) is kept as stated.
*
* @var \DateTimeZone
*/
private $wallClock;
/**
* @var array|null pickup master data by BusPro id
*/
private $pickups;
public function __construct(string $path, ?LoggerInterface $logger = null)
{
$this->path = rtrim($path, '/');
$this->logger = $logger ?? new NullLogger();
$this->timezone = new \DateTimeZone(self::TIMEZONE);
$this->wallClock = new \DateTimeZone('UTC');
}
public function getPath(): string
{
return $this->path;
}
/**
* The export's own timestamp from uebertragung.info, or null if unreadable.
*/
public function getSourceGeneratedAt(): ?\DateTimeImmutable
{
$file = $this->path . '/' . self::MARKER_FILE;
if (!is_file($file)) {
return null;
}
$line = trim((string) strtok((string) file_get_contents($file), "\r\n"));
$date = \DateTimeImmutable::createFromFormat(self::MARKER_TS_FORMAT, $line, $this->timezone);
return $date ?: null;
}
/**
* @return \Generator<array>
*/
public function getIterator(): \Generator
{
if ($this->pickups === null) {
$this->pickups = $this->readPickups();
}
$folder = opendir($this->path);
if ($folder === false) {
$this->logger->error('product xml directory not readable', ['path' => $this->path]);
return;
}
try {
while (($fileName = readdir($folder)) !== false) {
if (strpos($fileName, self::PRODUCT_PREFIX) === 0) {
yield from $this->readProductFile($this->path . '/' . $fileName);
}
}
} finally {
closedir($folder);
}
}
private function readProductFile(string $file): \Generator
{
libxml_use_internal_errors(true);
$xmlData = simplexml_load_string((string) file_get_contents($file));
if ($xmlData === false) {
libxml_clear_errors();
$this->logger->error('product xml invalid', ['file' => $file]);
return;
}
$xmlProduct = $xmlData->reise[0];
if ($xmlProduct === null) {
return;
}
$product = [
'code' => (string) $xmlProduct['code'],
'busProId' => (int) $xmlProduct['idbuspro'],
];
foreach ($xmlProduct->termin as $xmlDate) {
$from = $this->parseDate((string) $xmlDate['termin']);
$to = $this->parseDate((string) $xmlDate['bis']);
if ($from === null || $to === null) {
$this->logger->warning('travel date skipped, dates unparseable', [
'code' => (string) $xmlDate['code'],
'termin' => (string) $xmlDate['termin'],
'bis' => (string) $xmlDate['bis'],
]);
continue;
}
$date = [
'code' => (string) $xmlDate['code'],
'busProId' => (int) $xmlDate['idbuspro'],
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
'type' => (string) $xmlDate['reiseart'],
];
// Everything below is per travel date and shared by its hotels.
$selectionGroups = $this->readSelectionGroups($xmlDate);
$transportServices = $this->readServices($xmlDate, 'lei_befoerderung');
$dateServices = $this->readServices($xmlDate, 'lei_sonstiges');
$pickups = $this->readDatePickups($xmlDate, $from);
$guide = $this->readGuide($xmlDate);
foreach ($xmlDate->hotel as $xmlHotel) {
[$pseudoPrice, $rooms] = $this->readRooms($xmlHotel);
yield [
'product' => $product,
'date' => $date,
'hotel' => [
'code' => (string) $xmlHotel['code'],
'busProId' => (int) $xmlHotel['idbuspro'],
],
'pseudoPrice' => $pseudoPrice,
'selectionGroups' => $selectionGroups,
'transportServices' => $transportServices,
'dateServices' => $dateServices,
'hotelServices' => $this->readServices($xmlHotel, 'lei_sonstiges'),
'rooms' => $rooms,
'pickups' => $pickups,
'guide' => $guide,
];
}
}
}
private function readSelectionGroups(\SimpleXMLElement $xmlDate): array
{
$groups = [];
foreach ($xmlDate->selektiongruppe as $xmlGroup) {
$selections = [];
foreach ($xmlGroup->selektion as $xmlSelection) {
$selections[] = [
'busProId' => (int) $xmlSelection['idbuspro'],
'label' => (string) $xmlSelection['bezeichnung'],
];
}
$groups[] = [
'busProId' => (int) $xmlGroup['idbuspro'],
'label' => (string) $xmlGroup['bezeichnung'],
'selections' => $selections,
];
}
return $groups;
}
private function readServices(\SimpleXMLElement $xmlParent, string $list): array
{
$services = [];
foreach ($xmlParent->xpath($list . '/leistung') as $xmlService) {
$direction = trim((string) $xmlService->richtung);
$services[] = [
'busProId' => (int) $xmlService['idbuspro'],
'subType' => (string) $xmlService['unterart'],
'label' => $this->normalizeWhitespace((string) $xmlService->text),
'price' => $this->parsePrice((string) $xmlService->preis),
'status' => (string) $xmlService->status,
'direction' => $direction === '' ? null : $direction,
];
}
return $services;
}
/**
* @return array{0: ?float, 1: array}
*/
private function readRooms(\SimpleXMLElement $xmlHotel): array
{
$pseudoPrice = null;
$rooms = [];
foreach ($xmlHotel->xpath('zimmer/preis') as $xmlRoom) {
$code = trim((string) $xmlRoom['zimmercode']);
if ($code === self::CODE_PSEUDOPRICE) {
$pseudoPrice = $this->parsePrice((string) $xmlRoom['preis']);
continue;
}
$rooms[] = [
'busProId' => (int) $xmlRoom['idbuspro_zimmer'],
'code' => $code,
'label' => (string) $xmlRoom['zimmertext'],
'category' => (string) $xmlRoom['kat'],
'minPax' => $this->parseInt((string) $xmlRoom['MinPax']),
'maxPax' => $this->parseInt((string) $xmlRoom['MaxPax']),
'nights' => $this->parseInt((string) $xmlRoom['naechte']),
'price' => $this->parsePrice((string) $xmlRoom['preis']),
'available' => $this->parseInt((string) $xmlRoom['verfuegbar']),
'status' => (string) $xmlRoom['status'],
'boardCode' => (string) $xmlRoom['vpcode'],
'boardBusProId' => $this->parseInt((string) $xmlRoom['idbuspro_vp']),
];
}
return [$pseudoPrice, $rooms];
}
/**
* A bare HH:mm takes the travel date's start, and is rolled onto the next
* day when it would otherwise fall before the latest full date-time listed
* before it: a coach does not board backwards. The result is the local
* date and time as BusPro states it, without an offset.
*/
private function readDatePickups(\SimpleXMLElement $xmlDate, \DateTimeImmutable $from): array
{
$pickups = [];
$latest = null;
foreach ($xmlDate->xpath('zustiege/zustieg') as $xmlPickup) {
$busProId = (int) $xmlPickup['idbuspro'];
$time = trim((string) $xmlPickup['zeit']);
$datetime = null;
if ($time !== '' && strpos($time, ' ') !== false) {
$datetime = \DateTimeImmutable::createFromFormat('!d.m.Y H:i:s', $time, $this->wallClock) ?: null;
if ($datetime !== null && ($latest === null || $datetime > $latest)) {
$latest = $datetime;
}
} elseif ($time !== '') {
$datetime = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $from->format('Y-m-d') . ' ' . $time, $this->wallClock) ?: null;
if ($datetime !== null && $latest !== null && $datetime < $latest) {
$datetime = $datetime->modify('+1 day');
}
}
$masterData = $this->pickups[$busProId] ?? null;
$pickups[] = [
'busProId' => $busProId,
'code' => $masterData['code'] ?? null,
'city' => $masterData['city'] ?? null,
'street' => $masterData['street'] ?? null,
'price' => $this->parsePrice((string) $xmlPickup['preis']),
'datetime' => $datetime !== null ? $datetime->format(self::LOCAL_DATETIME_FORMAT) : null,
];
}
return $pickups;
}
private function readGuide(\SimpleXMLElement $xmlDate): ?array
{
foreach ($xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]') as $xmlService) {
if ((string) $xmlService->richtung === 'RUECK') {
continue;
}
if (!isset($xmlService->zustiegsplanung->reiseleiter)) {
continue;
}
return [
'name' => (string) $xmlService->zustiegsplanung->reiseleiter->name,
'phone' => (string) $xmlService->zustiegsplanung->reiseleiter->telefon,
];
}
return null;
}
/**
* @return array<int, array{code: string, city: string, street: string}>
*/
private function readPickups(): array
{
$file = $this->path . '/' . self::PICKUPS_FILE;
libxml_use_internal_errors(true);
if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) {
libxml_clear_errors();
$this->logger->warning('pickup xml missing or invalid', ['file' => $file]);
return [];
}
$pickups = [];
foreach ($xmlData->zustieg as $xmlPickup) {
$pickups[(int) $xmlPickup['idbuspro']] = [
'code' => (string) $xmlPickup['code'],
'city' => $this->normalizeWhitespace((string) $xmlPickup->ort),
'street' => $this->normalizeWhitespace((string) $xmlPickup->strasse),
];
}
return $pickups;
}
private function normalizeWhitespace(string $value): string
{
return trim((string) preg_replace('/\s+/u', ' ', $value));
}
private function parseDate(string $value): ?\DateTimeImmutable
{
$date = \DateTimeImmutable::createFromFormat('!d.m.Y', trim($value), $this->timezone);
return $date ?: null;
}
/**
* German format with an optional thousands separator: "1.234,50" is 1234.5.
*/
private function parsePrice(string $value): ?float
{
$value = str_replace(['.', ','], ['', '.'], trim($value));
return is_numeric($value) ? (float) $value : null;
}
private function parseInt(string $value): ?int
{
$value = trim($value);
return is_numeric($value) ? (int) $value : null;
}
}
@@ -44,16 +44,16 @@ class ImportDatesTask extends AbstractTask
$cacheService = GeneralUtility::makeInstance(CacheService::class); $cacheService = GeneralUtility::makeInstance(CacheService::class);
// Acquire the export only when it is newer than the one we last imported // Acquire the export only when it is newer than the one we last imported
// (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport). // (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport;
// An acquisition failure (SFTP connect/auth/transfer) is left to propagate // API mode: walk the MyE&P export pages). An acquisition failure is left
// so the scheduler marks the task failed. // to propagate so the scheduler marks the task failed.
$path = $importSource->acquire(); $import = $importSource->acquire();
if ($path !== null) { if ($import !== null) {
$importService->import($path); $importService->import($import->getRows());
// Advance the "last imported" bookkeeping only after a successful // Advance the "last imported" bookkeeping only after a successful
// import, so a crashed import retries on the next run. // import, so a crashed import retries on the next run.
$importSource->markImported($path); $importSource->markImported($import);
} }
$settings = $this->getSettings(); $settings = $this->getSettings();
@@ -0,0 +1,83 @@
<?php
namespace EP\EpProducts\Utility;
/**
* Maps hotel fields to icon ids and labels. Shared by the MyEP API and the frontend
* detail pages, so both show the same selection. Icon ids correspond to the
* "icon-{id}" symbols in ep_theme's icons_sprite.svg (copied from MyEP).
*/
class HotelIconUtility
{
/**
* @param array $hotel Hotel values keyed by database column name
* @return array<int, array{icon: string, label: string}>
*/
public static function getIcons(array $hotel): array
{
$icons = [];
if (($hotel['beds_count'] ?? 0) > 0) {
$icons[] = [
'icon' => 'bed',
'label' => sprintf('%d Betten', $hotel['beds_count']),
];
}
if (($hotel['parking_count'] ?? 0) > 0) {
$icons[] = [
'icon' => 'parking',
'label' => sprintf('%d Parkplätze', $hotel['parking_count']),
];
}
if ($hotel['altitude'] ?? null) {
$icons[] = [
'icon' => 'mountain',
'label' => $hotel['altitude'],
];
}
if ($hotel['distance_bus'] ?? null) {
$icons[] = [
'icon' => 'bus',
'label' => $hotel['distance_bus'],
];
}
if ($hotel['distance_lift'] ?? null) {
$icons[] = [
'icon' => 'lift',
'label' => $hotel['distance_lift'],
];
}
if ($hotel['shower'] ?? null) {
$icons[] = [
'icon' => 'shower',
'label' => $hotel['shower'],
];
}
if ($hotel['partyroom'] ?? false) {
$icons[] = [
'icon' => 'party',
'label' => 'Partyzimmer',
];
}
if ($hotel['gamesroom'] ?? false) {
$icons[] = [
'icon' => 'dice',
'label' => 'Spielezimmer',
];
}
if ($hotel['sauna'] ?? false) {
$icons[] = [
'icon' => 'sauna',
'label' => 'Sauna',
];
}
if ($hotel['wifi'] ?? false) {
$icons[] = [
'icon' => 'wifi',
'label' => 'WiFi',
];
}
return $icons;
}
}
@@ -20,6 +20,15 @@ return [
'typo3/cms-frontend/site', 'typo3/cms-frontend/site',
], ],
], ],
'ep/content-page-type-guard' => [
'target' => \EP\EpProducts\Middleware\ContentPageTypeGuardMiddleware::class,
'after' => [
'typo3/cms-frontend/page-resolver',
],
'before' => [
'typo3/cms-frontend/page-argument-validator',
],
],
'ep/staging-environment' => [ 'ep/staging-environment' => [
'target' => \EP\EpProducts\Middleware\StagingEnvironmentMiddleware::class, 'target' => \EP\EpProducts\Middleware\StagingEnvironmentMiddleware::class,
'after' => [ 'after' => [
@@ -403,6 +403,7 @@ return [
'label' => 'Produkte', 'label' => 'Produkte',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectMultipleSideBySide', 'renderType' => 'selectMultipleSideBySide',
'foreign_table' => 'tx_epproducts_domain_model_product', 'foreign_table' => 'tx_epproducts_domain_model_product',
'size' => 10, 'size' => 10,
@@ -24,8 +24,8 @@ return [
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'hidden, bus_pro_id, hotel_bus_pro_id, code, daytrip, date_start, date_end, nights, board, '1' => ['showitem' => 'hidden, bus_pro_id, hotel_bus_pro_id, code, daytrip, date_start, date_end, nights, board,
board_code, board_bus_pro_id, bus_included, bus_available, skipass_included, services_included, board_code, board_bus_pro_id, bus_included, bus_available, skipass_included, group_travel,
services_general, courses_included, guide, pickups, discount, min_price, pseudo_price, available, min_pax, services_included, services_general, courses_included, guide, pickups, discount, min_price, pseudo_price, available, min_pax,
new' new'
], ],
], ],
@@ -322,6 +322,14 @@ return [
'default' => 0 'default' => 0
] ]
], ],
'group_travel' => [
'exclude' => false,
'label' => 'Gruppenreise',
'config' => [
'type' => 'check',
'default' => 0
]
],
'bus_price' => [ 'bus_price' => [
'exclude' => false, 'exclude' => false,
'label' => 'Bus Preis', 'label' => 'Bus Preis',
@@ -52,7 +52,7 @@ epTeamApiTimeout = 5
# cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited) # cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited)
epTeamApiMaxDuration = 15 epTeamApiMaxDuration = 15
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source # cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local,MyE&P API=api]; label=Product/date import source
productImportSource = sftp productImportSource = sftp
# cat=SftpImport; type=string; label=SFTP host for product/date import # cat=SftpImport; type=string; label=SFTP host for product/date import
@@ -75,3 +75,21 @@ productImportSftpHostKey =
# cat=SftpImport; type=int+; label=SFTP connection timeout in seconds # cat=SftpImport; type=int+; label=SFTP connection timeout in seconds
productImportSftpTimeout = 15 productImportSftpTimeout = 15
# cat=ContentFeed; type=string; label=API key for the CMS backchannel endpoints used by MyE&P (/api/hotel, /api/product, /api/content/*); sent as X-Api-Key or ?key=. Empty denies every request.
cmsApiKey =
# cat=ContentFeed; type=boolean; label=Serve the chatbot content feed (off: all feed endpoints answer 503)
contentFeedEnabled = 1
# cat=ContentFeed; type=string; label=Site identifiers included in the chatbot content feed (comma-separated)
contentFeedSites = ep-reisen,ser,ski-boarderweek,ski-wochenenden,snowzone
# cat=ContentFeed; type=string; label=Language ids included in the chatbot content feed (comma-separated; the English pages are untranslated placeholders today)
contentFeedLanguages = 0
# cat=ContentFeed; type=string; label=Page uids whose subtrees stay out of the chatbot content feed (comma-separated; 634 = group booking links)
contentFeedExcludedPages = 634
# cat=ContentFeed; type=string; label=News detail page per site for the chatbot content feed (identifier:pageUid, comma-separated)
contentFeedNewsDetailPages = ep-reisen:1392
@@ -117,15 +117,16 @@ CREATE TABLE tx_epproducts_domain_model_date
bus_included tinyint(1) unsigned DEFAULT '0' NOT NULL, bus_included tinyint(1) unsigned DEFAULT '0' NOT NULL,
bus_available tinyint(1) unsigned DEFAULT '0' NOT NULL, bus_available tinyint(1) unsigned DEFAULT '0' NOT NULL,
skipass_included tinyint(1) unsigned DEFAULT '0' NOT NULL, skipass_included tinyint(1) unsigned DEFAULT '0' NOT NULL,
bus_price int(11) DEFAULT '0' NOT NULL, group_travel tinyint(1) unsigned DEFAULT '0' NOT NULL,
bus_price decimal(10,2) DEFAULT '0.00' NOT NULL,
services_included text NOT NULL, services_included text NOT NULL,
services_general text NOT NULL, services_general text NOT NULL,
courses_included text NOT NULL, courses_included text NOT NULL,
pickups text NOT NULL, pickups text NOT NULL,
guide text NOT NULL, guide text NOT NULL,
discount int(11) DEFAULT '0' NOT NULL, discount decimal(10,2) DEFAULT '0.00' NOT NULL,
min_price int(11) DEFAULT '0' NOT NULL, min_price decimal(10,2) DEFAULT '0.00' NOT NULL,
pseudo_price int(11) DEFAULT '0' NOT NULL, pseudo_price decimal(10,2) DEFAULT '0.00' NOT NULL,
available int(11) DEFAULT '0' NOT NULL, available int(11) DEFAULT '0' NOT NULL,
min_pax int(11) DEFAULT '0' NOT NULL, min_pax int(11) DEFAULT '0' NOT NULL,
product int(11) unsigned DEFAULT '0', product int(11) unsigned DEFAULT '0',
@@ -456,7 +457,7 @@ CREATE TABLE tx_epproducts_domain_model_room
name varchar(255) DEFAULT '' NOT NULL, name varchar(255) DEFAULT '' NOT NULL,
code varchar(255) DEFAULT '' NOT NULL, code varchar(255) DEFAULT '' NOT NULL,
type int(11) DEFAULT '0' NOT NULL, type int(11) DEFAULT '0' NOT NULL,
price int(11) DEFAULT '0' NOT NULL, price decimal(10,2) DEFAULT '0.00' NOT NULL,
services text NOT NULL, services text NOT NULL,
nights int(11) DEFAULT '0' NOT NULL, nights int(11) DEFAULT '0' NOT NULL,
pax int(11) DEFAULT '0' NOT NULL, pax int(11) DEFAULT '0' NOT NULL,
@@ -0,0 +1,45 @@
<?php
namespace EP\EpTheme\ViewHelpers;
use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithContentArgumentAndRenderStatic;
/**
* Formats a euro amount the way the website always has for whole euros ("229",
* "1234"), and with German decimals where there are cents ("99,90"). Prices are
* stored with cents, and the database hands them over as strings like "229.00".
*
* Keep in sync with formatPrice() in pricetable_controller.js.
*
* Usage: {row.minPrice -> ep:price()} €
*/
class PriceViewHelper extends AbstractViewHelper
{
use CompileWithContentArgumentAndRenderStatic;
public function initializeArguments()
{
parent::initializeArguments();
$this->registerArgument('value', 'mixed', 'Amount in euros');
}
/**
* @param array $arguments
* @param \Closure $renderChildrenClosure
* @param RenderingContextInterface $renderingContext
* @return string
*/
public static function renderStatic(
array $arguments,
\Closure $renderChildrenClosure,
RenderingContextInterface $renderingContext
) {
$amount = round((float) $renderChildrenClosure(), 2);
$decimals = floor($amount) === $amount ? 0 : 2;
return number_format($amount, $decimals, ',', '');
}
}
@@ -24,6 +24,7 @@ call_user_func(function() {
'label' => 'Land', 'label' => 'Land',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_country', 'foreign_table' => 'tx_epproducts_domain_model_country',
'foreign_table_where' => 'ORDER BY name', 'foreign_table_where' => 'ORDER BY name',
@@ -39,6 +40,7 @@ call_user_func(function() {
'label' => 'Gebiet', 'label' => 'Gebiet',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_region', 'foreign_table' => 'tx_epproducts_domain_model_region',
'foreign_table_where' => 'ORDER BY name', 'foreign_table_where' => 'ORDER BY name',
@@ -106,6 +108,7 @@ call_user_func(function() {
'label' => 'Badge', 'label' => 'Badge',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_badge', 'foreign_table' => 'tx_epproducts_domain_model_badge',
'foreign_table_where' => 'ORDER BY title', 'foreign_table_where' => 'ORDER BY title',
@@ -22,6 +22,7 @@ call_user_func(function() {
'label' => 'Mitarbeiter', 'label' => 'Mitarbeiter',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epproducts_domain_model_teammember', 'foreign_table' => 'tx_epproducts_domain_model_teammember',
'foreign_table_where' => 'ORDER BY name', 'foreign_table_where' => 'ORDER BY name',
@@ -22,3 +22,9 @@ plugin.tx_bwcaptcha.settings {
width = 220 width = 220
height = 60 height = 60
} }
# Requests with a malformed type (e.g. ?type=03413) bypass the bw_captcha middleware's
# strict string check and get rendered as this empty PAGE. If it were cacheable, genuine
# ?type=3413 requests would hit that cache entry, TypoScript would never be parsed and
# the middleware would crash on empty settings.
captcha.config.no_cache = 1
@@ -0,0 +1,62 @@
# Chatbot content feed: the main content of a page, without header, navigation, footer or
# assets. Fetched by MyE&P for pages the content manifest reports as changed; protected by
# EP\EpProducts\Middleware\ContentPageTypeGuardMiddleware. See
# .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §6.
# Plugins (the booking widget on product detail pages), forms, sub-page menus and teaser cards
# are left out: MyE&P has trip data first-hand, and the pages and records the cards link to are
# in the feed in their own right. The filter applies to top-level elements only; a plugin nested
# in a grid or container element is still rendered by its parent.
lib.contentOnly < lib.dynamicContent
lib.contentOnly.20.select.where = colPos={register:colPos} AND CType NOT IN ({$plugin.tx_eptheme.settings.contentOnlyExcludedCTypes})
tx_eptheme_content_only = PAGE
tx_eptheme_content_only {
typeNum = 1730
config {
disableAllHeaderCode = 1
disablePrefixComment = 1
additionalHeaders.10.header = Content-Type: text/html; charset=utf-8
additionalHeaders.20.header = X-Robots-Tag: noindex, nofollow, noarchive
admPanel = 0
debug = 0
# Never written to disk: the type is key-protected, and a static file would be served
# by .htaccess without that check ever running.
tx_staticfilecache.disableCache = 1
# Same reason for shared caches between us and the consumer: the TYPO3 page cache is
# fine (the guard runs before it), a proxy storing a keyed response is not.
sendCacheHeaders = 0
additionalHeaders.30.header = Cache-Control: private, no-store
}
10 = COA
10 {
wrap = <article>|</article>
10 = TEXT
10.field = title
10.htmlSpecialChars = 1
10.wrap = <h1>|</h1>
# Page templates without a sidebar render colPos 0; the sidebar layouts render 3 (header),
# 4 (content) and 2 (sidebar). colPos 1 holds teaser groups and is left out. The page
# templates pass colPos in as data; here there is no template, so each copy sets it.
# These must be copies (<), not references (=<): removing colPos.cObject on a reference
# leaves the referenced cObject in place, and every column silently renders colPos 0.
20 < lib.contentOnly
20.5.colPos.cObject >
20.5.colPos = 3
30 < lib.contentOnly
30.5.colPos.cObject >
30.5.colPos = 0
40 < lib.contentOnly
40.5.colPos.cObject >
40.5.colPos = 4
50 < lib.contentOnly
50.5.colPos.cObject >
50.5.colPos = 2
}
}
@@ -153,6 +153,8 @@ plugin.tx_eptheme {
xmlSitemapRootPageUid = xmlSitemapRootPageUid =
# cat=eptheme/600/110; type=string; label=XML sitemap page UIDs to exclude # cat=eptheme/600/110; type=string; label=XML sitemap page UIDs to exclude
xmlSitemapExcludeUids = xmlSitemapExcludeUids =
# cat=eptheme/600/120; type=string; label=CTypes left out of the chatbot content-only page type (quoted, comma-separated)
contentOnlyExcludedCTypes = 'list','form_formframework','menu_subpages','teaser_product','teaser_hotel','teaser_concept','teaser_country','teaser_region','ep_container_teasergroup'
# cat=eptheme/300/100; type=string; label=Cookiefirst Script # cat=eptheme/300/100; type=string; label=Cookiefirst Script
cookieFirstScriptUrl = https://consent.cookiefirst.com/sites/ep-reisen.de-fabb1554-8932-4075-85dc-97fa68892918/consent.js cookieFirstScriptUrl = https://consent.cookiefirst.com/sites/ep-reisen.de-fabb1554-8932-4075-85dc-97fa68892918/consent.js
@@ -26,12 +26,9 @@
<symbol id="icon-email" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></symbol> <symbol id="icon-email" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></symbol>
<symbol id="icon-tag" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></symbol> <symbol id="icon-tag" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></symbol>
<symbol id="icon-faq" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol> <symbol id="icon-faq" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol>
<symbol id="icon-bed" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M176 256c44.11 0 80-35.89 80-80s-35.89-80-80-80-80 35.89-80 80 35.89 80 80 80zm352-128H304c-8.84 0-16 7.16-16 16v144H64V80c0-8.84-7.16-16-16-16H16C7.16 64 0 71.16 0 80v352c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16v-48h512v48c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V240c0-61.86-50.14-112-112-112z"/></symbol> <symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M18 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8" /><path d="M16 5l1.5 7l4.5 0" /><path d="M2 10l15 0" /><path d="M7 5l0 5" /><path d="M12 5l0 5" /></symbol>
<symbol id="icon-bus" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M488 128h-8V80c0-44.8-99.2-80-224-80S32 35.2 32 80v48h-8c-13.25 0-24 10.74-24 24v80c0 13.25 10.75 24 24 24h8v160c0 17.67 14.33 32 32 32v32c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32v-32h192v32c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32v-32h6.4c16 0 25.6-12.8 25.6-25.6V256h8c13.25 0 24-10.75 24-24v-80c0-13.26-10.75-24-24-24zM112 400c-17.67 0-32-14.33-32-32s14.33-32 32-32 32 14.33 32 32-14.33 32-32 32zm16-112c-17.67 0-32-14.33-32-32V128c0-17.67 14.33-32 32-32h256c17.67 0 32 14.33 32 32v128c0 17.67-14.33 32-32 32H128zm272 112c-17.67 0-32-14.33-32-32s14.33-32 32-32 32 14.33 32 32-14.33 32-32 32z"/></symbol>
<symbol id="icon-location-marker" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></symbol> <symbol id="icon-location-marker" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></symbol>
<symbol id="icon-ski" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M432 96c26.5 0 48-21.5 48-48S458.5 0 432 0s-48 21.5-48 48 21.5 48 48 48zm-312-4.4l-11.5 22.5c14.4 7.3 31.1 4.9 42.8-4.8L284 175.5l61.1-24.4 14.1 42.3c3.4 10.1 10.5 18.3 20.1 23.1l58.1 29c11.9 6 26.3 1.1 32.2-10.7 5.9-11.9 1.1-26.3-10.8-32.2L403.6 175l-18.1-54.4c-4.9-14.6-15.6-26.6-29.6-33.1-14-6.5-30.1-6.9-44.3-1.2l-74.5 29.8-72.2-35.8c.3-14.5-7.2-28.5-20.9-35.6l-11.1 21.7h-.3l-34.4-7c-1.8-.4-3.7.2-5 1.7-1.9 2.2-1.7 5.5.5 7.4L120 91.6zM505 452c-9.4-9.4-24.6-9.3-33.9 0-12.1 12.1-30.7 15.3-45.1 8.7l-143-73.9 49.7-74.6c10.6-15.8 8.5-37.1-5-50.5l-57.2-57.2-76.9-38.1c-1.6 16.6 3.8 33 15.7 44.9l79.8 79.8-49.1 73.6-205-106c-11.9-6.1-26.3-1.5-32.3 10.3-6.1 11.8-1.5 26.3 10.3 32.3l391.9 202.5c11.9 5.5 24.5 8.1 37.1 8.1 23.2 0 46.1-9 63-26 9.3-9.3 9.3-24.5 0-33.9z"/></symbol> <symbol id="icon-ski" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M432 96c26.5 0 48-21.5 48-48S458.5 0 432 0s-48 21.5-48 48 21.5 48 48 48zm-312-4.4l-11.5 22.5c14.4 7.3 31.1 4.9 42.8-4.8L284 175.5l61.1-24.4 14.1 42.3c3.4 10.1 10.5 18.3 20.1 23.1l58.1 29c11.9 6 26.3 1.1 32.2-10.7 5.9-11.9 1.1-26.3-10.8-32.2L403.6 175l-18.1-54.4c-4.9-14.6-15.6-26.6-29.6-33.1-14-6.5-30.1-6.9-44.3-1.2l-74.5 29.8-72.2-35.8c.3-14.5-7.2-28.5-20.9-35.6l-11.1 21.7h-.3l-34.4-7c-1.8-.4-3.7.2-5 1.7-1.9 2.2-1.7 5.5.5 7.4L120 91.6zM505 452c-9.4-9.4-24.6-9.3-33.9 0-12.1 12.1-30.7 15.3-45.1 8.7l-143-73.9 49.7-74.6c10.6-15.8 8.5-37.1-5-50.5l-57.2-57.2-76.9-38.1c-1.6 16.6 3.8 33 15.7 44.9l79.8 79.8-49.1 73.6-205-106c-11.9-6.1-26.3-1.5-32.3 10.3-6.1 11.8-1.5 26.3 10.3 32.3l391.9 202.5c11.9 5.5 24.5 8.1 37.1 8.1 23.2 0 46.1-9 63-26 9.3-9.3 9.3-24.5 0-33.9z"/></symbol>
<symbol id="icon-mountain" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M634.92 462.7l-288-448C341.03 5.54 330.89 0 320 0s-21.03 5.54-26.92 14.7l-288 448a32.001 32.001 0 0 0-1.17 32.64A32.004 32.004 0 0 0 32 512h576c11.71 0 22.48-6.39 28.09-16.67a31.983 31.983 0 0 0-1.17-32.63zM61.31 464l131.77-204.98L256 321.94 329.94 248h109.9L578.7 464H61.31zM320 61.59L408.98 200h-98.92L256 254.06l-36.36-36.36L320 61.59z"/></symbol>
<symbol id="icon-lift" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M112 128c26.5 0 48-21.5 48-48s-21.5-48-48-48-48 21.5-48 48 21.5 48 48 48zM256 0h-32v216l32-8V0zm-64.4 381.4c12.6-4.2 19.4-17.8 15.2-30.4-4.2-12.6-17.7-19.5-30.4-15.2L158 342c-19 6.4-40-2.5-48.7-20.7l-63.6-133c-5.7-11.9-20-17-32-11.3-12 5.7-17 20-11.3 32L66 342c15 31.2 46.4 50 79.5 50 12.6 0 17-.9 46.1-10.6zM488 288c-13.2 0-24 10.8-24 24 0 13.9-8.8 26.5-21.8 31.3L312 391.5V256c0-15.8-15-27-29.8-23.3l-93.5 23.4-39.7-85.5c-7.4-16-26.3-23.1-42.5-15.6-12.6 5.8-24.1 24.2-15.7 42.5l47.3 100.6c4.8 10.5 16.5 16.1 27.6 13.2l98.2-24.5v122.5l-152.3 56.3c-12.4 4.6-18.8 18.4-14.2 30.8 3.6 9.7 12.8 15.7 22.5 15.7 2.8 0 5.6-.5 8.3-1.5l330.5-122.1c31.8-11.8 53.2-42.5 53.2-76.4.1-13.3-10.7-24.1-23.9-24.1z"/></symbol>
<symbol id="icon-concept" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></symbol> <symbol id="icon-concept" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></symbol>
<symbol id="icon-emoji-happy" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol> <symbol id="icon-emoji-happy" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol>
<symbol id="icon-emoji-sad" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol> <symbol id="icon-emoji-sad" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></symbol>
@@ -66,4 +63,23 @@
<symbol id="icon-soundcloud" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M111.4 256.3l5.8 65-5.8 68.3c-.3 2.5-2.2 4.4-4.4 4.4s-4.2-1.9-4.2-4.4l-5.6-68.3 5.6-65c0-2.2 1.9-4.2 4.2-4.2 2.2 0 4.1 2 4.4 4.2zm21.4-45.6c-2.8 0-4.7 2.2-5 5l-5 105.6 5 68.3c.3 2.8 2.2 5 5 5 2.5 0 4.7-2.2 4.7-5l5.8-68.3-5.8-105.6c0-2.8-2.2-5-4.7-5zm25.5-24.1c-3.1 0-5.3 2.2-5.6 5.3l-4.4 130 4.4 67.8c.3 3.1 2.5 5.3 5.6 5.3 2.8 0 5.3-2.2 5.3-5.3l5.3-67.8-5.3-130c0-3.1-2.5-5.3-5.3-5.3zM7.2 283.2c-1.4 0-2.2 1.1-2.5 2.5L0 321.3l4.7 35c.3 1.4 1.1 2.5 2.5 2.5s2.2-1.1 2.5-2.5l5.6-35-5.6-35.6c-.3-1.4-1.1-2.5-2.5-2.5zm23.6-21.9c-1.4 0-2.5 1.1-2.5 2.5l-6.4 57.5 6.4 56.1c0 1.7 1.1 2.8 2.5 2.8s2.5-1.1 2.8-2.5l7.2-56.4-7.2-57.5c-.3-1.4-1.4-2.5-2.8-2.5zm25.3-11.4c-1.7 0-3.1 1.4-3.3 3.3L47 321.3l5.8 65.8c.3 1.7 1.7 3.1 3.3 3.1 1.7 0 3.1-1.4 3.1-3.1l6.9-65.8-6.9-68.1c0-1.9-1.4-3.3-3.1-3.3zm25.3-2.2c-1.9 0-3.6 1.4-3.6 3.6l-5.8 70 5.8 67.8c0 2.2 1.7 3.6 3.6 3.6s3.6-1.4 3.9-3.6l6.4-67.8-6.4-70c-.3-2.2-2-3.6-3.9-3.6zm241.4-110.9c-1.1-.8-2.8-1.4-4.2-1.4-2.2 0-4.2.8-5.6 1.9-1.9 1.7-3.1 4.2-3.3 6.7v.8l-3.3 176.7 1.7 32.5 1.7 31.7c.3 4.7 4.2 8.6 8.9 8.6s8.6-3.9 8.6-8.6l3.9-64.2-3.9-177.5c-.4-3-2-5.8-4.5-7.2zm-26.7 15.3c-1.4-.8-2.8-1.4-4.4-1.4s-3.1.6-4.4 1.4c-2.2 1.4-3.6 3.9-3.6 6.7l-.3 1.7-2.8 160.8s0 .3 3.1 65.6v.3c0 1.7.6 3.3 1.7 4.7 1.7 1.9 3.9 3.1 6.4 3.1 2.2 0 4.2-1.1 5.6-2.5 1.7-1.4 2.5-3.3 2.5-5.6l.3-6.7 3.1-58.6-3.3-162.8c-.3-2.8-1.7-5.3-3.9-6.7zm-111.4 22.5c-3.1 0-5.8 2.8-5.8 6.1l-4.4 140.6 4.4 67.2c.3 3.3 2.8 5.8 5.8 5.8 3.3 0 5.8-2.5 6.1-5.8l5-67.2-5-140.6c-.2-3.3-2.7-6.1-6.1-6.1zm376.7 62.8c-10.8 0-21.1 2.2-30.6 6.1-6.4-70.8-65.8-126.4-138.3-126.4-17.8 0-35 3.3-50.3 9.4-6.1 2.2-7.8 4.4-7.8 9.2v249.7c0 5 3.9 8.6 8.6 9.2h218.3c43.3 0 78.6-35 78.6-78.3.1-43.6-35.2-78.9-78.5-78.9zm-296.7-60.3c-4.2 0-7.5 3.3-7.8 7.8l-3.3 136.7 3.3 65.6c.3 4.2 3.6 7.5 7.8 7.5 4.2 0 7.5-3.3 7.5-7.5l3.9-65.6-3.9-136.7c-.3-4.5-3.3-7.8-7.5-7.8zm-53.6-7.8c-3.3 0-6.4 3.1-6.4 6.7l-3.9 145.3 3.9 66.9c.3 3.6 3.1 6.4 6.4 6.4 3.6 0 6.4-2.8 6.7-6.4l4.4-66.9-4.4-145.3c-.3-3.6-3.1-6.7-6.7-6.7zm26.7 3.4c-3.9 0-6.9 3.1-6.9 6.9L227 321.3l3.9 66.4c.3 3.9 3.1 6.9 6.9 6.9s6.9-3.1 6.9-6.9l4.2-66.4-4.2-141.7c0-3.9-3-6.9-6.9-6.9z"/></symbol> <symbol id="icon-soundcloud" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M111.4 256.3l5.8 65-5.8 68.3c-.3 2.5-2.2 4.4-4.4 4.4s-4.2-1.9-4.2-4.4l-5.6-68.3 5.6-65c0-2.2 1.9-4.2 4.2-4.2 2.2 0 4.1 2 4.4 4.2zm21.4-45.6c-2.8 0-4.7 2.2-5 5l-5 105.6 5 68.3c.3 2.8 2.2 5 5 5 2.5 0 4.7-2.2 4.7-5l5.8-68.3-5.8-105.6c0-2.8-2.2-5-4.7-5zm25.5-24.1c-3.1 0-5.3 2.2-5.6 5.3l-4.4 130 4.4 67.8c.3 3.1 2.5 5.3 5.6 5.3 2.8 0 5.3-2.2 5.3-5.3l5.3-67.8-5.3-130c0-3.1-2.5-5.3-5.3-5.3zM7.2 283.2c-1.4 0-2.2 1.1-2.5 2.5L0 321.3l4.7 35c.3 1.4 1.1 2.5 2.5 2.5s2.2-1.1 2.5-2.5l5.6-35-5.6-35.6c-.3-1.4-1.1-2.5-2.5-2.5zm23.6-21.9c-1.4 0-2.5 1.1-2.5 2.5l-6.4 57.5 6.4 56.1c0 1.7 1.1 2.8 2.5 2.8s2.5-1.1 2.8-2.5l7.2-56.4-7.2-57.5c-.3-1.4-1.4-2.5-2.8-2.5zm25.3-11.4c-1.7 0-3.1 1.4-3.3 3.3L47 321.3l5.8 65.8c.3 1.7 1.7 3.1 3.3 3.1 1.7 0 3.1-1.4 3.1-3.1l6.9-65.8-6.9-68.1c0-1.9-1.4-3.3-3.1-3.3zm25.3-2.2c-1.9 0-3.6 1.4-3.6 3.6l-5.8 70 5.8 67.8c0 2.2 1.7 3.6 3.6 3.6s3.6-1.4 3.9-3.6l6.4-67.8-6.4-70c-.3-2.2-2-3.6-3.9-3.6zm241.4-110.9c-1.1-.8-2.8-1.4-4.2-1.4-2.2 0-4.2.8-5.6 1.9-1.9 1.7-3.1 4.2-3.3 6.7v.8l-3.3 176.7 1.7 32.5 1.7 31.7c.3 4.7 4.2 8.6 8.9 8.6s8.6-3.9 8.6-8.6l3.9-64.2-3.9-177.5c-.4-3-2-5.8-4.5-7.2zm-26.7 15.3c-1.4-.8-2.8-1.4-4.4-1.4s-3.1.6-4.4 1.4c-2.2 1.4-3.6 3.9-3.6 6.7l-.3 1.7-2.8 160.8s0 .3 3.1 65.6v.3c0 1.7.6 3.3 1.7 4.7 1.7 1.9 3.9 3.1 6.4 3.1 2.2 0 4.2-1.1 5.6-2.5 1.7-1.4 2.5-3.3 2.5-5.6l.3-6.7 3.1-58.6-3.3-162.8c-.3-2.8-1.7-5.3-3.9-6.7zm-111.4 22.5c-3.1 0-5.8 2.8-5.8 6.1l-4.4 140.6 4.4 67.2c.3 3.3 2.8 5.8 5.8 5.8 3.3 0 5.8-2.5 6.1-5.8l5-67.2-5-140.6c-.2-3.3-2.7-6.1-6.1-6.1zm376.7 62.8c-10.8 0-21.1 2.2-30.6 6.1-6.4-70.8-65.8-126.4-138.3-126.4-17.8 0-35 3.3-50.3 9.4-6.1 2.2-7.8 4.4-7.8 9.2v249.7c0 5 3.9 8.6 8.6 9.2h218.3c43.3 0 78.6-35 78.6-78.3.1-43.6-35.2-78.9-78.5-78.9zm-296.7-60.3c-4.2 0-7.5 3.3-7.8 7.8l-3.3 136.7 3.3 65.6c.3 4.2 3.6 7.5 7.8 7.5 4.2 0 7.5-3.3 7.5-7.5l3.9-65.6-3.9-136.7c-.3-4.5-3.3-7.8-7.5-7.8zm-53.6-7.8c-3.3 0-6.4 3.1-6.4 6.7l-3.9 145.3 3.9 66.9c.3 3.6 3.1 6.4 6.4 6.4 3.6 0 6.4-2.8 6.7-6.4l4.4-66.9-4.4-145.3c-.3-3.6-3.1-6.7-6.7-6.7zm26.7 3.4c-3.9 0-6.9 3.1-6.9 6.9L227 321.3l3.9 66.4c.3 3.9 3.1 6.9 6.9 6.9s6.9-3.1 6.9-6.9l4.2-66.4-4.2-141.7c0-3.9-3-6.9-6.9-6.9z"/></symbol>
<symbol id="icon-whatsapp" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M380.9 97.1C339 55.1 283.2 32 223.9 32c-122.4 0-222 99.6-222 222 0 39.1 10.2 77.3 29.6 111L0 480l117.7-30.9c32.4 17.7 68.9 27 106.1 27h.1c122.3 0 224.1-99.6 224.1-222 0-59.3-25.2-115-67.1-157zm-157 341.6c-33.2 0-65.7-8.9-94-25.7l-6.7-4-69.8 18.3L72 359.2l-4.4-7c-18.5-29.4-28.2-63.3-28.2-98.2 0-101.7 82.8-184.5 184.6-184.5 49.3 0 95.6 19.2 130.4 54.1 34.8 34.9 56.2 81.2 56.1 130.5 0 101.8-84.9 184.6-186.6 184.6zm101.2-138.2c-5.5-2.8-32.8-16.2-37.9-18-5.1-1.9-8.8-2.8-12.5 2.8-3.7 5.6-14.3 18-17.6 21.8-3.2 3.7-6.5 4.2-12 1.4-32.6-16.3-54-29.1-75.5-66-5.7-9.8 5.7-9.1 16.3-30.3 1.8-3.7.9-6.9-.5-9.7-1.4-2.8-12.5-30.1-17.1-41.2-4.5-10.8-9.1-9.3-12.5-9.5-3.2-.2-6.9-.2-10.6-.2-3.7 0-9.7 1.4-14.8 6.9-5.1 5.6-19.4 19-19.4 46.3 0 27.3 19.9 53.7 22.6 57.4 2.8 3.7 39.1 59.7 94.8 83.8 35.2 15.2 49 16.5 66.6 13.9 10.7-1.6 32.8-13.4 37.4-26.4 4.6-13 4.6-24.1 3.2-26.4-1.3-2.5-5-3.9-10.5-6.6z"/></symbol> <symbol id="icon-whatsapp" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Pro 5.15.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M380.9 97.1C339 55.1 283.2 32 223.9 32c-122.4 0-222 99.6-222 222 0 39.1 10.2 77.3 29.6 111L0 480l117.7-30.9c32.4 17.7 68.9 27 106.1 27h.1c122.3 0 224.1-99.6 224.1-222 0-59.3-25.2-115-67.1-157zm-157 341.6c-33.2 0-65.7-8.9-94-25.7l-6.7-4-69.8 18.3L72 359.2l-4.4-7c-18.5-29.4-28.2-63.3-28.2-98.2 0-101.7 82.8-184.5 184.6-184.5 49.3 0 95.6 19.2 130.4 54.1 34.8 34.9 56.2 81.2 56.1 130.5 0 101.8-84.9 184.6-186.6 184.6zm101.2-138.2c-5.5-2.8-32.8-16.2-37.9-18-5.1-1.9-8.8-2.8-12.5 2.8-3.7 5.6-14.3 18-17.6 21.8-3.2 3.7-6.5 4.2-12 1.4-32.6-16.3-54-29.1-75.5-66-5.7-9.8 5.7-9.1 16.3-30.3 1.8-3.7.9-6.9-.5-9.7-1.4-2.8-12.5-30.1-17.1-41.2-4.5-10.8-9.1-9.3-12.5-9.5-3.2-.2-6.9-.2-10.6-.2-3.7 0-9.7 1.4-14.8 6.9-5.1 5.6-19.4 19-19.4 46.3 0 27.3 19.9 53.7 22.6 57.4 2.8 3.7 39.1 59.7 94.8 83.8 35.2 15.2 49 16.5 66.6 13.9 10.7-1.6 32.8-13.4 37.4-26.4 4.6-13 4.6-24.1 3.2-26.4-1.3-2.5-5-3.9-10.5-6.6z"/></symbol>
<symbol id="icon-tiktok" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/></symbol> <symbol id="icon-tiktok" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/></symbol>
<symbol id="icon-backpack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M96,48h64a48,48,0,0,1,48,48V216a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V96A48,48,0,0,1,96,48Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M80,224V152a16,16,0,0,1,16-16h64a16,16,0,0,1,16,16v72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,48V32a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="88" x2="144" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="168" x2="176" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="144" y1="168" x2="144" y2="184" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-barricade" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="24" y="72" width="208" height="88" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="84" y1="72" x2="172" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="156" y1="72" x2="232" y2="148" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="84" x2="100" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="192" y1="160" x2="192" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="64" y1="160" x2="64" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-bed" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M112,168V80H216a32,32,0,0,1,32,32v56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="208" x2="24" y2="48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="24 168 248 168 248 208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="80" x2="24" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-boot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="152" y1="120" x2="112" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="88" x2="112" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M240,168v32a8,8,0,0,1-8,8H205.89a8.08,8.08,0,0,1-3.58-.84l-12.62-6.32a8.08,8.08,0,0,0-3.58-.84H169.89a8.08,8.08,0,0,0-3.58.84l-12.62,6.32a8.08,8.08,0,0,1-3.58.84H121.89a8.08,8.08,0,0,1-3.58-.84l-12.62-6.32a8.08,8.08,0,0,0-3.58-.84H85.89a8.08,8.08,0,0,0-3.58.84l-12.62,6.32a8.08,8.08,0,0,1-3.58.84H40a8,8,0,0,1-8-8V168c9.22-32.06,12-88.65,0-120H144a8,8,0,0,1,8,8v64h40A48,48,0,0,1,240,168Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="32" y1="168" x2="240" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-lift" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="16" y1="72" x2="240" y2="32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="40" y="104" width="176" height="112" rx="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="168" x2="216" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="52" x2="128" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="160" y1="104" x2="160" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="96" y1="104" x2="96" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-chat" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M71.58,144,32,176V48a8,8,0,0,1,8-8H168a8,8,0,0,1,8,8v88a8,8,0,0,1-8,8Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M80,144v40a8,8,0,0,0,8,8h96.42L224,224V96a8,8,0,0,0-8-8H176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-weather" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="87.66" y1="56.73" x2="83.5" y2="33.09" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="56.69" y1="76.46" x2="37.03" y2="62.69" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48.73" y1="112.31" x2="25.09" y2="116.48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="123.52" y1="64.69" x2="137.28" y2="45.03" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,144a68.06,68.06,0,1,1,68,72H84a44,44,0,1,1,14.2-85.66" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M59.65,135.35a48,48,0,1,1,80.19-50.94" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-party" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M40.49,205.52,93,61.14a7.79,7.79,0,0,1,12.84-2.85l91.88,91.88A7.79,7.79,0,0,1,194.86,163L50.48,215.51A7.79,7.79,0,0,1,40.49,205.52Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M168,72s0-24,24-24,24-24,24-24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="144" y1="16" x2="144" y2="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="216" y1="112" x2="232" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="216" y1="80" x2="240" y2="72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="78.09" y1="102.09" x2="153.91" y2="177.91" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="101.11" y1="197.11" x2="58.89" y2="154.89" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-dice" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><rect width="256" height="256" fill="none"/><rect x="40" y="40" width="176" height="176" rx="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="92" cy="84" r="12"/><circle cx="164" cy="84" r="12"/><circle cx="92" cy="128" r="12"/><circle cx="164" cy="128" r="12"/><circle cx="92" cy="172" r="12"/><circle cx="164" cy="172" r="12"/></symbol>
<symbol id="icon-flag" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="48" y1="224" x2="48" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M48,176c64-55.43,112,55.43,176,0V56C160,111.43,112,.57,48,56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-burger" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M48.07,96a8,8,0,0,1-7.89-9.72C46.68,55.55,83.52,32,128,32s81.32,23.55,87.82,54.28A8,8,0,0,1,207.93,96Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="24 176 68 160 108 176 148 160 188 176 232 160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="128" x2="232" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M208,168.73V184a32,32,0,0,1-32,32H80a32,32,0,0,1-32-32V167.27" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-handshake" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="200 152 160 192 96 176 40 136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="72.68 70.63 128 56 183.32 70.63" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M34.37,60.42,8.85,111.48a8,8,0,0,0,3.57,10.73L40,136,72.68,70.63,45.11,56.85A8,8,0,0,0,34.37,60.42Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,136l27.58-13.79a8,8,0,0,0,3.57-10.73L221.63,60.42a8,8,0,0,0-10.74-3.57L183.32,70.63Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,72H144L98.34,116.29a8,8,0,0,0,1.38,12.42C117.23,139.9,141,139.13,160,120l40,32,16-16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="124.06 216 82.34 205.57 56 186.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-badge" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="152" y1="112" x2="192" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="144" x2="192" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="96" cy="120" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M64,168c3.55-13.8,17.09-24,32-24s28.46,10.19,32,24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-parking" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M104,144h32a28,28,0,0,0,0-56H104v80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-drinks" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M56,32H200L176.85,225A8,8,0,0,1,168.9,232H87.1A8,8,0,0,1,79.15,225Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="60.8" y1="72" x2="195.2" y2="72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-sauna" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M153.599 76.8c-35.285 0-64 28.706-64 64s28.715 64 64 64 64-28.715 64-64-28.706-64-64-64Zm0 102.4c-21.205 0-38.4-17.195-38.4-38.4s17.195-38.4 38.4-38.4 38.4 17.195 38.4 38.4-17.194 38.4-38.4 38.4Zm152.824 315.623-51.2-140.8c-1.835-5.06-6.639-8.422-12.023-8.422h-89.6V230.4c0-7.074-5.726-12.8-12.8-12.8S128 223.326 128 230.4v128c0 7.074 5.726 12.8 12.8 12.8h93.44l48.137 132.378C284.263 508.775 289.178 512 294.4 512c1.451 0 2.927-.247 4.378-.776 6.638-2.415 10.077-9.754 7.645-16.401ZM209.919 396.8h-107.52c-7.074 0-12.8 5.726-12.8 12.8s5.726 12.8 12.8 12.8h107.52c7.074 0 12.8-5.726 12.8-12.8s-5.726-12.8-12.8-12.8ZM340.36 7.074c-3.157-6.323-10.863-8.875-17.169-5.726-6.323 3.166-8.892 10.846-5.726 17.178 22.886 45.79 12.228 85.726-.111 131.959-7.603 28.424-15.454 57.839-15.991 90.914-.111 7.074 5.521 12.902 12.587 13.013h.213c6.972 0 12.689-5.598 12.8-12.587.486-29.935 7.927-57.796 15.113-84.736 12.552-46.975 25.514-95.547-1.716-150.015Zm65.98 0c-3.174-6.323-10.837-8.875-17.178-5.726-6.323 3.166-8.892 10.846-5.726 17.178 22.903 45.79 12.237 85.726-.111 131.977-13.141 49.203-28.041 104.977-.324 174.251 1.997 5.009 6.81 8.047 11.887 8.047 1.587 0 3.2-.29 4.753-.913 6.562-2.628 9.762-10.078 7.134-16.64-24.559-61.389-11.426-110.575 1.271-158.148C420.599 110.123 433.578 61.551 406.34 7.074Z"/></symbol>
<symbol id="icon-shower" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M64,236a12,12,0,1,1-12-12A12,12,0,0,1,64,236Zm20-44a12,12,0,1,0,12,12A12,12,0,0,0,84,192Zm-64,0a12,12,0,1,0,12,12A12,12,0,0,0,20,192Zm32-32a12,12,0,1,0,12,12A12,12,0,0,0,52,160ZM256,40a8,8,0,0,1-8,8H219.31L191.46,75.86,169.8,202.65a16,16,0,0,1-27.09,8.66l-98-98a16,16,0,0,1,8.69-27.1L180.14,64.54l30.2-30.2A8,8,0,0,1,216,32h32A8,8,0,0,1,256,40ZM174.21,81.79,56,102l98,98Z"/></symbol>
<symbol id="icon-wifi" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><rect width="256" height="256" fill="none"/><circle cx="128" cy="204" r="12"/><path d="M232,93.19a164,164,0,0,0-208,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M200,129a116,116,0,0,0-144,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M168,165a68,68,0,0,0-80,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
<symbol id="icon-mountain" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="164" cy="52" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M8,200,81.1,75.94a8,8,0,0,1,13.8,0L168,200Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="50.35" y1="128" x2="125.65" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M146.61,163.71l33.06-55.79a8,8,0,0,1,13.76,0L248,200H168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 37 KiB

@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
export default class extends Controller { export default class extends Controller {
static targets = [ 'sizer', 'container', 'button' ] static targets = [ 'sizer', 'container', 'button', 'bar' ]
static values = { static values = {
offset: { offset: {
@@ -33,6 +33,12 @@ export default class extends Controller {
// Set initial expanded/collapsed status and button visibility // Set initial expanded/collapsed status and button visibility
this.expandedValue = this.contentHeight < this.maxHeight this.expandedValue = this.contentHeight < this.maxHeight
this.buttonTarget.classList.toggle('hidden', this.expandedValue) this.buttonTarget.classList.toggle('hidden', this.expandedValue)
// Drop the space reserved for the toggle bar when the content fits anyway
if (this.expandedValue && this.hasBarTarget) {
this.barTarget.classList.add('hidden')
this.containerTarget.style.paddingBottom = '0'
}
} }
toggle() { toggle() {
@@ -42,13 +42,13 @@ export default class extends Controller {
modeValueChanged() { modeValueChanged() {
this.priceTargets.forEach(price => { this.priceTargets.forEach(price => {
let room = parseInt(price.dataset.room) let room = parseFloat(price.dataset.room)
let bus = parseInt(price.dataset.bus) let bus = parseFloat(price.dataset.bus)
let discount = parseInt(price.dataset.discount) let discount = parseFloat(price.dataset.discount)
price.innerText = `${this.calculatePrice(room, bus, discount)} €` price.innerText = `${this.formatPrice(this.calculatePrice(room, bus, discount))} €`
}) })
this.busIconTargets.forEach(icon => { this.busIconTargets.forEach(icon => {
let busPrice = parseInt(icon.dataset.busPrice) let busPrice = parseFloat(icon.dataset.busPrice)
let busIncluded = !!icon.dataset.busIncluded let busIncluded = !!icon.dataset.busIncluded
icon.classList.toggle('hidden', this.isHideBusIcon(busPrice, busIncluded)) icon.classList.toggle('hidden', this.isHideBusIcon(busPrice, busIncluded))
}) })
@@ -87,6 +87,16 @@ export default class extends Controller {
return minPrice return minPrice
} }
// Keep in sync with EP\EpTheme\ViewHelpers\PriceViewHelper: whole euros
// as before ("229"), German decimals where there are cents ("99,90").
formatPrice(amount) {
let cents = Math.round(amount * 100)
if (cents % 100 === 0) {
return String(cents / 100)
}
return (cents / 100).toFixed(2).replace('.', ',')
}
isHideBusIcon(busPrice, busIncluded) { isHideBusIcon(busPrice, busIncluded) {
return ( return (
busPrice && this.modeValue !== 'surcharge' || busPrice && this.modeValue !== 'surcharge' ||
@@ -8,7 +8,7 @@
<f:render partial="Disrupter/Outlines" section="Top1" arguments="{classes: '-translate-y-2 text-white'}"/> <f:render partial="Disrupter/Outlines" section="Top1" arguments="{classes: '-translate-y-2 text-white'}"/>
</f:if> </f:if>
<div class="py-4 relative"> <div class="py-4 relative">
<div class="container grid grid-cols-2 lg:grid-cols-4 gap-8 pb-4"> <div class="container grid md:grid-cols-2 lg:grid-cols-4 gap-8 pb-4 text-center md:text-left">
<f:if condition="{settings.footerNavPid}"> <f:if condition="{settings.footerNavPid}">
<f:for each="{footernav}" as="menuheader"> <f:for each="{footernav}" as="menuheader">
<div> <div>
@@ -32,7 +32,7 @@
</f:if> </f:if>
</div> </div>
<div class="container py-4 text-white text-xl border-t border-white"> <div class="container py-4 text-white text-xl border-t border-white text-center md:text-left">
{totalRatings.totalAverage -> f:format.number(decimalSeparator: ',')} Sterne bei {totalRatings.totalCount} Bewertungen {totalRatings.totalAverage -> f:format.number(decimalSeparator: ',')} Sterne bei {totalRatings.totalCount} Bewertungen
</div> </div>
@@ -184,10 +184,8 @@
<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:comment><!--
<f:if condition="{site.identifier} == 'ep-reisen'"> <f:if condition="{site.identifier} == 'ep-reisen'">
<f:render partial="Chatbot" arguments="{_all}"/> <f:render partial="Chatbot" arguments="{_all}"/>
</f:if> </f:if>
--></f:comment>
</html> </html>
@@ -12,7 +12,11 @@
errorClass="form-field--has-error" errorClass="form-field--has-error"
placeholder="Bitte die Zeichen aus der Grafik eingeben" placeholder="Bitte die Zeichen aus der Grafik eingeben"
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"/> additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"/>
<img src="{f:uri.page(pageType:3413)}" alt="Captcha image" loading="lazy" onload="this.parentElement.classList.remove('captcha--reloading'); return false;"/> <f:comment>
The "now" param keeps bw_captcha 3.x from sending a Content-Length that is the base64 size of the
binary image (too large), which makes Chrome drop the response. Fixed upstream in bw_captcha 4.0.1.
</f:comment>
<img src="{f:uri.page(pageType:3413)}&amp;now=0" alt="Captcha image" loading="lazy" onload="this.parentElement.classList.remove('captcha--reloading'); return false;"/>
<f:if condition="{element.properties.showRefresh}"> <f:if condition="{element.properties.showRefresh}">
<button type="button" <button type="button"
class="captcha__reload" class="captcha__reload"
@@ -14,6 +14,16 @@
</h2> </h2>
</f:else> </f:else>
</f:if> </f:if>
<f:if condition="{hotel.icons}">
<ul class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 !list-none !pl-0 mb-4">
<f:for each="{hotel.icons}" as="item">
<li class="flex items-center space-x-2">
{ep:icon(icon: item.icon, class: 'w-5 h-5 md:w-6 md:h-6 shrink-0 text-ep-primary')}
<span class="text-sm md:text-base">{item.label}</span>
</li>
</f:for>
</ul>
</f:if>
{hotel.description -> f:format.html()} {hotel.description -> f:format.html()}
</div> </div>
<f:if condition="{teasers}"> <f:if condition="{teasers}">
@@ -39,7 +39,7 @@
{hotel.teaser -> f:format.crop(maxCharacters: settings.teaserTextMaxChars) -> f:format.html()} {hotel.teaser -> f:format.crop(maxCharacters: settings.teaserTextMaxChars) -> f:format.html()}
<f:if condition="{hotel.available} != -1"> <f:if condition="{hotel.available} != -1">
<span class="teaserbox__price"> <span class="teaserbox__price">
<strong>{f:if(condition: '{hotel.available} > 0', then: 'ab {hotel.minPrice} €', else: 'aktuell nicht buchbar')}</strong> <strong><f:if condition="{hotel.available} > 0"><f:then>ab {hotel.minPrice -> ep:price()} €</f:then><f:else>aktuell nicht buchbar</f:else></f:if></strong>
</span> </span>
</f:if> </f:if>
</div> </div>
@@ -2,7 +2,7 @@
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">
<div class="odd:bg-zinc-50 p-4" data-rte-content> <div class="group odd:bg-zinc-50 p-4" data-rte-content>
<f:if condition="{journey.byBus}"> <f:if condition="{journey.byBus}">
<h3> <h3>
Busanreise Busanreise
@@ -11,6 +11,9 @@
{journey.byBus -> f:format.html()} {journey.byBus -> f:format.html()}
</div> </div>
</f:if> </f:if>
<f:if condition="{pickupsOverview.pickups}">
<f:render partial="Product/Pickups" arguments="{pickupsOverview: pickupsOverview}"/>
</f:if>
<f:if condition="{journey.byCar}"> <f:if condition="{journey.byCar}">
<h3> <h3>
Eigenanreise Eigenanreise
@@ -53,10 +53,10 @@
<td class="px-2 py-1 border border-zinc-200"> <td class="px-2 py-1 border border-zinc-200">
<f:if condition="{row.available}"> <f:if condition="{row.available}">
<f:then> <f:then>
{row.minPrice} € {row.minPrice -> ep:price()} €
</f:then> </f:then>
<f:else> <f:else>
<s>{row.minPrice} €</s> <s>{row.minPrice -> ep:price()} €</s>
</f:else> </f:else>
</f:if> </f:if>
</td> </td>
@@ -0,0 +1,81 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="pb-8">
<h3>
Bus-Zustiege
</h3>
<f:if condition="{pickupsOverview.pickups -> f:count()} <= 3">
<f:then>
<f:render section="Table" arguments="{_all}"/>
</f:then>
<f:else>
<div data-controller="autocollapse" data-autocollapse-height-value="400" data-autocollapse-offset-value="64">
<div class="relative pb-12" data-autocollapse-target="container">
<f:render section="Table" arguments="{_all}"/>
<div class="absolute left-0 bottom-0 w-full h-12 bg-white group-odd:bg-zinc-50 flex items-center" data-autocollapse-target="bar">
<button type="button"
data-autocollapse-target="button"
data-action="autocollapse#toggle"
class="hidden text-ep-primary w-10 h-10 ml-2 group toggle-more">
<f:render section="ToggleIcon"/>
</button>
</div>
</div>
</div>
</f:else>
</f:if>
<p class="text-sm">
Abfahrtszeiten und -orte unter Vorbehalt. Die genauen Zeiten werden euch kurz vor der Anreise mitgeteilt.
</p>
</div>
<f:section name="Table">
<table class="w-full bg-zinc-200 text-zinc-800 text-xs md:text-base mb-4">
<tr>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Zustieg
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Abfahrt
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base">
Zuschlag
</th>
</tr>
<f:for each="{pickupsOverview.pickups}" as="pickup">
<tr class="odd:bg-zinc-50 align-top">
<td class="border-r border-zinc-200 px-2 py-1 md:p-2">
{pickup.city}<br><span class="text-xs">{pickup.street}</span>
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 whitespace-nowrap">
<f:if condition="{pickup.time}">
<f:then>{pickup.time} Uhr</f:then>
<f:else>-</f:else>
</f:if>
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 whitespace-nowrap">
<f:if condition="{pickup.price} > 0">
<f:then>{pickup.price -> f:format.currency(currencySign: '€', decimalSeparator: ',', thousandsSeparator: '.')}</f:then>
<f:else>-</f:else>
</f:if>
</td>
</tr>
</f:for>
</table>
</f:section>
<f:section name="ToggleIcon">
<svg viewBox="0 0 184 185" xmlns="http://www.w3.org/2000/svg" stroke="none" fill="currentColor">
<g>
<path d="M92,0C41.2,0 0,41.2 0,92.1C0,143 41.2,184.1 92,184.1C142.8,184.1 184,142.9 184,92.1C184,41.3 142.8,0 92,0ZM92,173.1C47.3,173.1 11,136.8 11,92.1C11,47.4 47.3,11.1 92,11.1C136.7,11.1 173,47.4 173,92.1C173,136.8 136.7,173.1 92,173.1Z"/>
<path d="M134.2,92.1C134.2,93.6 133.7,95 132.6,96C131.5,97.1 130.3,97.6 128.7,97.6L55.3,97.6C53.8,97.6 52.4,97.1 51.4,96C50.3,94.9 49.8,93.7 49.8,92.1C49.8,90.5 50.3,89.2 51.4,88.2C52.5,87.1 53.7,86.6 55.3,86.6L128.6,86.6C130.1,86.6 131.5,87.1 132.5,88.2C133.6,89.3 134.1,90.5 134.1,92.1"/>
</g>
<g class="group-[.toggle-less]:hidden">
<path d="M97,128.7C97,130.2 96.5,131.6 95.4,132.6C94.3,133.7 93.1,134.2 91.5,134.2C89.9,134.2 88.6,133.7 87.6,132.6C86.5,131.5 86,130.3 86,128.7L86,55.4C86,53.9 86.5,52.5 87.6,51.5C88.7,50.4 89.9,49.9 91.5,49.9C93.1,49.9 94.4,50.4 95.4,51.5C96.5,52.6 97,53.8 97,55.4L97,128.7Z"/>
</g>
</svg>
</f:section>
</html>
@@ -104,7 +104,7 @@
</ul> </ul>
<div class="bg-ep-primary-dark text-white font-bold px-4 py-1 peer-hover:bg-badge-gradient peer-hover:text-white transition-colors"> <div class="bg-ep-primary-dark text-white font-bold px-4 py-1 peer-hover:bg-badge-gradient peer-hover:text-white transition-colors">
<div class="lg:inline-block"> <div class="lg:inline-block">
ab <span class="product-teaser__price mx-1">{minPrice} €</span> ab <span class="product-teaser__price mx-1">{minPrice -> ep:price()} €</span>
</div> </div>
<div class="product-teaser__label-price"> <div class="product-teaser__label-price">
p. P.{f:if(condition: '{season} == "w" && {skiPassIncluded}', then: ' / inkl. Skipass')} p. P.{f:if(condition: '{season} == "w" && {skiPassIncluded}', then: ' / inkl. Skipass')}
@@ -129,9 +129,9 @@
</td> </td>
</f:if> </f:if>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-center"> <td class="border-r border-zinc-200 px-2 py-1 md:p-2 text-center">
<f:if condition="{row.pseudoPrice}"> <f:if condition="{row.pseudoPrice} > 0">
<span class="block text-sm line-through"> <span class="block text-sm line-through">
{row.pseudoPrice} € {row.pseudoPrice -> ep:price()} €
</span> </span>
</f:if> </f:if>
<span class="font-bold normal-case text-sm md:text-base whitespace-nowrap" <span class="font-bold normal-case text-sm md:text-base whitespace-nowrap"
@@ -139,7 +139,7 @@
data-room="{row.minPrice}" data-room="{row.minPrice}"
data-bus="{row.busPrice}" data-bus="{row.busPrice}"
data-discount="{row.discount}"> data-discount="{row.discount}">
{row.minPrice} {row.minPrice -> ep:price()}
</span> </span>
</td> </td>
<td class="px-2 py-1 md:p-2 text-center"> <td class="px-2 py-1 md:p-2 text-center">
@@ -61,14 +61,14 @@
<f:link.typolink <f:link.typolink
parameter="{row.hotelDetailPageUid}" parameter="{row.hotelDetailPageUid}"
class="whitespace-nowrap {ep:themeClasses(classes: '{sbw: \'text-sbw-primary\', snz: \'text-snz-primary\', uch: \'text-uch-primary\'}', key: settings.themekey)}"> class="whitespace-nowrap {ep:themeClasses(classes: '{sbw: \'text-sbw-primary\', snz: \'text-snz-primary\', uch: \'text-uch-primary\'}', key: settings.themekey)}">
{row.rooms.{type}.price} {row.rooms.{type}.price -> ep:price()}
{f:if(condition: '{row.rooms.{type}.undersubscription}', then: '*')} {f:if(condition: '{row.rooms.{type}.undersubscription}', then: '*')}
</f:link.typolink> </f:link.typolink>
</f:then> </f:then>
<f:else> <f:else>
<f:if condition="{row.rooms.{type}.available}"> <f:if condition="{row.rooms.{type}.available}">
<f:else> <f:else>
<span class="line-through text-zinc-500">{row.rooms.{type}.price}</span> <span class="line-through text-zinc-500">{row.rooms.{type}.price -> ep:price()}</span>
</f:else> </f:else>
</f:if> </f:if>
</f:else> </f:else>
@@ -266,7 +266,7 @@
data-room="{row.minPrice}" data-room="{row.minPrice}"
data-bus="{row.busPrice}" data-bus="{row.busPrice}"
data-discount="{row.discount}"> data-discount="{row.discount}">
{row.minPrice} {row.minPrice -> ep:price()}
</span> </span>
</a> </a>
</f:then> </f:then>
@@ -279,7 +279,7 @@
data-room="{row.minPrice}" data-room="{row.minPrice}"
data-bus="{row.busPrice}" data-bus="{row.busPrice}"
data-discount="{row.discount}"> data-discount="{row.discount}">
{row.minPrice} € {row.minPrice -> ep:price()} €
</span> </span>
</f:else> </f:else>
</f:if> </f:if>
@@ -42,7 +42,7 @@
<f:render section="TabButton" arguments="{tab: 'hotel', label: 'Unterkunft', wide: 0}"/> <f:render section="TabButton" arguments="{tab: 'hotel', label: 'Unterkunft', wide: 0}"/>
<f:render section="TabButton" arguments="{tab: 'region', label: 'Gebiet', wide: 0}"/> <f:render section="TabButton" arguments="{tab: 'region', label: 'Gebiet', wide: 0}"/>
<f:render section="TabButton" arguments="{tab: 'city', label: 'Ort', wide: 0}"/> <f:render section="TabButton" arguments="{tab: 'city', label: 'Ort', wide: 0}"/>
<f:if condition="{product.journey}"> <f:if condition="{product.journey} || {pickupsOverview.pickups}">
<f:render section="TabButton" arguments="{tab: 'journey', label: 'Anreise', wide: 0}"/> <f:render section="TabButton" arguments="{tab: 'journey', label: 'Anreise', wide: 0}"/>
</f:if> </f:if>
</div> </div>
@@ -158,11 +158,11 @@
</f:section> </f:section>
<f:section name="Journey"> <f:section name="Journey">
<f:if condition="{product.journey}"> <f:if condition="{product.journey} || {pickupsOverview.pickups}">
<div class="mb-4 hidden" <div class="mb-4 hidden"
data-tabs-target="tab" data-tabs-target="tab"
data-tab="journey"> data-tab="journey">
<f:render partial="Journey/Details" arguments="{journey: product.journey, bars: 1}"/> <f:render partial="Journey/Details" arguments="{journey: product.journey, pickupsOverview: pickupsOverview, bars: 1}"/>
</div> </div>
</f:if> </f:if>
</f:section> </f:section>
@@ -127,7 +127,7 @@
</tr> </tr>
<tr> <tr>
<th>Abpreis</th> <th>Abpreis</th>
<td>{date.minprice} €</td> <td>{date.minprice -> ep:price()} €</td>
</tr> </tr>
<tr> <tr>
<th>Bus inkl.</th> <th>Bus inkl.</th>
@@ -154,7 +154,7 @@
<tr> <tr>
<td>{room.name}</td> <td>{room.name}</td>
<td>{v:variable.get(name: 'room.@pax')}</td> <td>{v:variable.get(name: 'room.@pax')}</td>
<td>{room.price} €</td> <td>{room.price -> ep:price()} €</td>
<td>{room.available}</td> <td>{room.available}</td>
</tr> </tr>
</f:for> </f:for>