Compare commits

...
42 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
fromme d338529afc chore: cleanup .htaccess 2026-09-26 10:05:12 +02:00
fromme e3099bac5f feat: preconnect to the CDN host 2026-09-26 09:53:58 +02:00
fromme 322a59b31c feat: offer brotli for the assets the webhost now serves itself 2026-09-26 09:40:11 +02:00
fromme e96dd0f06d fix: resolve leaflet marker images through webpack 2026-09-26 09:35:58 +02:00
fromme f10d784a5c fix: serve theme assets from the production host, keep images on the CDN 2026-09-25 17:35:07 +02:00
fromme b5d0c484c0 fix: add missing template for date fields 2026-09-25 14:49:39 +02:00
fromme d8bb376424 fix: send form date values as YYYY-MM-DD strings to epteam api 2026-09-25 14:46:44 +02:00
fromme 38d575cbe9 fix: point the application form finisher at epteam 2026-09-23 09:26:12 +02:00
fromme f9e8704046 fix: datepicker obscured by logo in header searchbar 2026-09-23 09:24:25 +02:00
fromme 49547ecda3 fix: reuse the MyEP api connection instead of a client per call 2026-09-22 16:49:44 +02:00
80 changed files with 4256 additions and 489 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);
});
%}
+8 -3
View File
@@ -38,7 +38,6 @@ $rsyncOptions = [
'var', 'var',
'.editorconfig', '.editorconfig',
'.gitignore', '.gitignore',
'.htaccess_prod',
'deploy.php', 'deploy.php',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -52,7 +51,10 @@ $rsyncOptions = [
'filter' => [], 'filter' => [],
'filter-file' => false, 'filter-file' => false,
'filter-perdir' => false, 'filter-perdir' => false,
'flags' => 'rz', // -t preserves mtimes: without it every file lands with a fresh timestamp, so unchanged
// files get a new Last-Modified on every deploy and every conditional request revalidates
// into a full response instead of a 304.
'flags' => 'rzt',
'options' => ['delete', 'links', 'chmod=D755,F664'], 'options' => ['delete', 'links', 'chmod=D755,F664'],
'timeout' => 300, 'timeout' => 300,
]; ];
@@ -68,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"
}
}
+113 -9
View File
@@ -75,6 +75,58 @@ AddType image/webp .webp
</IfModule> </IfModule>
</IfModule> </IfModule>
# Brotli, where the host has the module. It beats gzip by roughly 15-20% on the theme's
# minified css/js at no measurable cost, and falls back to the mod_deflate block above for
# clients that do not announce `br` - which is also what happens wholesale if the module is
# missing, because the whole block is then skipped.
#
# The type list is deliberately the same one mod_deflate gets: `no-gzip` below switches gzip
# off for every br-capable client, so a type listed for DEFLATE only would reach those
# clients uncompressed.
<IfModule mod_brotli.c>
# Both filters would otherwise run for a client announcing `br, gzip` and gzip the brotli
# output on top of itself. mod_deflate honours `no-gzip`, so brotli wins where it is
# offered and gzip keeps everyone else.
<IfModule mod_setenvif.c>
SetEnvIfNoCase Accept-Encoding "(^|[\s,])br($|[\s,;])" no-gzip
</IfModule>
<IfModule mod_filter.c>
AddOutputFilterByType BROTLI_COMPRESS application/atom+xml \
application/javascript \
application/json \
application/ld+json \
application/manifest+json \
application/rdf+xml \
application/rss+xml \
application/schema+json \
application/vnd.geo+json \
application/vnd.ms-fontobject \
application/x-font-ttf \
application/x-javascript \
application/x-web-app-manifest+json \
application/xhtml+xml \
application/xml \
font/eot \
font/opentype \
image/bmp \
image/svg+xml \
image/vnd.microsoft.icon \
image/x-icon \
text/cache-manifest \
text/css \
text/html \
text/javascript \
text/plain \
text/vcard \
text/vnd.rim.location.xloc \
text/vtt \
text/x-component \
text/x-cross-domain-policy \
text/xml
</IfModule>
</IfModule>
### End: Compression ### ### End: Compression ###
@@ -139,6 +191,16 @@ AddType image/webp .webp
</IfModule> </IfModule>
# Webpack writes an 8-hex-character content hash into the filename of everything it emits, so
# these URLs can never change meaning - a new build is a new URL. `immutable` stops browsers
# revalidating them on reload, and overrides the per-type rules above, which would otherwise
# expire the hashed fonts after a month.
<IfModule mod_headers.c>
<FilesMatch "\.[0-9a-f]{8}\.(js|css|woff2?|jpe?g|png|gif|svg|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
### End: Browser caching of resource files ### ### End: Browser caching of resource files ###
@@ -223,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 ###
@@ -241,6 +300,57 @@ AddDefaultCharset utf-8
# Enable URL rewriting # Enable URL rewriting
RewriteEngine On RewriteEngine On
# Legacy and campaign domains, consolidated onto their current host. These have to stay
# ahead of the Force SSL block and of TYPO3's catch-all at the end of this file, or a
# request for one of them reaches index.php before it is ever redirected.
#
# Maintained here rather than on the host: the deploy rsyncs this file into every release
# (deploy.php), so anything edited live is gone after the next one.
RewriteRule ^buchung/(.*)$ https://bpn.ep-reisen.de/$1 [R=302,L,QSA]
RewriteCond %{HTTP_HOST} ^cllt\-touristik\.at$ [NC]
RewriteRule ^(.*) https://www.cllt-touristik.at/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^alpinevacation\.ch$ [NC]
RewriteRule ^(.*) https://www.alpinevacation.ch/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?semesterendeskireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^semesterende\-skireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^snowzone\.net$ [NC]
RewriteRule ^(.*) https://www.snowzone.net/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-boarderweek\.de$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ski\-boarderweek\.(com|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} skiboarderweek\.(de|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-wochenenden\.de$ [NC]
RewriteRule ^(.*) https://www.ski-wochenenden.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-events\.de$ [NC]
RewriteRule ^(.*) https://www.ep-events.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-reisen\.de$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
# The dropped path is deliberate: snow-break sends every deep link to the snowbreak
# landing page, unlike unichamp below, which keeps it.
RewriteCond %{HTTP_HOST} ^(www\.)?snow\-break\.(de|com) [NC]
RewriteRule (.*) https://snowbreak.ep-reisen.de/ [R=302,L]
# Matches the bare domain as well, so it lands on snowbreak in one hop - a separate
# unichamp.de -> www.unichamp.de rule used to run first and cost an extra 301.
RewriteCond %{HTTP_HOST} ^(www\.)?unichamp\.de$ [NC]
RewriteRule ^(.*) https://snowbreak.ep-reisen.de/$1 [L,R=302]
# Force SSL # Force SSL
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} =http RewriteCond %{HTTP:X-Forwarded-Proto} =http
@@ -282,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,244 @@
<?php
namespace EP\EpProducts\EpTeam;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The MyE&P Team application (Symfony). Separate from EP\EpProducts\MyEP\ApiClient on purpose:
* this is a different application on its own host, authenticated with a shared key in a header
* instead of the OAuth client-credentials flow MyE&P uses, so it shares neither the base URI nor
* the token handling.
*/
class ApiClient implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Status codes the endpoint answers a stored submission with. A conflict is included for the
* dedupe the team application may add later - it is not returned today - because a
* double-clicked submit button is not something to report.
*/
private const ACCEPTED_STATUS_CODES = [
Response::HTTP_OK,
Response::HTTP_CREATED,
Response::HTTP_ACCEPTED,
Response::HTTP_NO_CONTENT,
Response::HTTP_CONFLICT,
];
/**
* How much of a rejected response is carried into the exception message. A 400 names the
* offending field in its body, which is the only way to tell from a log why a submission
* was refused.
*/
private const ERROR_BODY_EXCERPT_LENGTH = 500;
/**
* Shared, like the MyE&P client: Symfony's CurlHttpClient keeps its connection pool and TLS
* session cache per instance. The API key is a constant, so unlike a bearer token it can be
* a client default header and the instance can live as long as the request.
*/
private static ?HttpClientInterface $httpClient = null;
private ?array $configuration = null;
/**
* Posts a submitted TYPO3 form to POST /api/application-forms.
*
* @throws ApiException
*/
public function submitApplicationForm(array $payload): void
{
$uri = 'api/application-forms';
// 'json' also sets the Content-Type the endpoint requires - without it the body is not
// read as JSON and the answer is a 400.
$response = $this->request('POST', $uri, [
'json' => $payload,
]);
$statusCode = $this->readStatusCode($response, 'POST', $uri);
if (false === in_array($statusCode, self::ACCEPTED_STATUS_CODES, true)) {
$message = sprintf(
'EP Team API returned unexpected status %d for POST %s: %s',
$statusCode,
$uri,
$this->readErrorBody($response)
);
$this->logWarning($message);
throw new ApiException($message, $statusCode);
}
$this->logAcceptedSubmission($response, $payload);
}
/**
* @throws ApiException
*/
private function request(string $method, string $uri, array $options): ResponseInterface
{
try {
return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
private function readErrorBody(ResponseInterface $response): string
{
try {
$body = trim($response->getContent(false));
} catch (TransportExceptionInterface $e) {
return '<no response body>';
}
if ('' === $body) {
return '<empty response body>';
}
if (mb_strlen($body) > self::ERROR_BODY_EXCERPT_LENGTH) {
$body = mb_substr($body, 0, self::ERROR_BODY_EXCERPT_LENGTH) . '…';
}
return $body;
}
/**
* The submission uuid the endpoint returns is what makes a support question ("did our
* application arrive?") answerable from the TYPO3 log alone. Reading the body also drains
* the response, which returns the connection to the pool instead of cancelling the transfer.
* Deliberately without any of the submitted values: these payloads are personal data.
*/
private function logAcceptedSubmission(ResponseInterface $response, array $payload): void
{
try {
$data = $response->toArray(false);
} catch (\Throwable $e) {
// A body that is absent (204) or not JSON costs nothing here - the submission is
// stored either way, which the asserted status has already established.
return;
}
if (null !== $this->logger) {
$this->logger->info('Application form submission accepted by the EP Team API.', [
'form' => $payload['form'] ?? null,
'pageUid' => $payload['pageUid'] ?? null,
'uuid' => $data['uuid'] ?? null,
]);
}
}
/**
* @throws ApiException
*/
private function getHttpClient(): HttpClientInterface
{
if (null !== static::$httpClient) {
return static::$httpClient;
}
$config = $this->getConfiguration();
$baseUrl = trim((string)($config['epTeamApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['epTeamApiKey'] ?? ''));
if ('' === $baseUrl) {
throw new ApiException('EP Team API base URL is not configured.');
}
if ('' === $apiKey) {
throw new ApiException('EP Team API key is not configured.');
}
if (0 !== strpos($baseUrl, 'https://')) {
// The application answers an http request with a 301 to the https URL, and this
// client does not follow redirects, so a plain http base URL fails every submission.
$this->logWarning(sprintf('EP Team API base URL is not https (%s); submissions will fail on the redirect.', $baseUrl));
}
$options = [
'headers' => [
'X-Api-Key' => $apiKey,
],
];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request - and this call happens
// inside the visitor's form submission, with the visitor waiting for it. The defaults
// live in code rather than in ext_conf_template.txt: ExtensionConfiguration::get()
// returns the stored configuration verbatim and only syncs the template when an
// extension has no configuration at all, so a newly added template key is absent until
// someone saves extension configuration by hand. Falling back to 0 there would silently
// mean "no timeout" - the opposite of what this guard is for. An explicit 0 still
// disables.
$timeout = (int)($config['epTeamApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['epTeamApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return static::$httpClient = HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
}
private function logError(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->error($message, $context);
}
}
private function logWarning(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->warning($message, $context);
}
}
}
@@ -0,0 +1,7 @@
<?php
namespace EP\EpProducts\EpTeam;
class ApiException extends \RuntimeException
{
}
@@ -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);
}
}
@@ -49,8 +49,22 @@ class ApiClient implements LoggerAwareInterface
*/ */
private const ACCESS_TOKEN_DEFAULT_TTL = 300; private const ACCESS_TOKEN_DEFAULT_TTL = 300;
/**
* Status codes that mean the token was rejected rather than the request being wrong.
*/
private const UNAUTHORIZED_STATUS_CODES = [Response::HTTP_UNAUTHORIZED, Response::HTTP_FORBIDDEN];
private static ?AccessToken $accessToken = null; private static ?AccessToken $accessToken = null;
/**
* Shared on purpose. Symfony's CurlHttpClient keeps its connection pool and TLS session
* cache in a CurlClientState owned by the instance, so building a client per call throws
* both away and forces a fresh DNS lookup, TCP connect and TLS handshake every time -
* TravelinfoController alone makes two calls per page render. The bearer token is passed
* per request rather than as a client default so one instance can outlive one token.
*/
private static ?HttpClientInterface $httpClient = null;
private ?FrontendInterface $cache = null; private ?FrontendInterface $cache = null;
private ?array $configuration = null; private ?array $configuration = null;
@@ -67,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]);
@@ -75,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
*/ */
@@ -103,20 +134,7 @@ class ApiClient implements LoggerAwareInterface
// A conflict means the address is already registered, which is the normal answer to a // A conflict means the address is already registered, which is the normal answer to a
// resubmitted contact form and not worth reporting. // resubmitted contact form and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform'); $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform');
} $this->drainResponse($response);
/**
* @throws ApiException
*/
public function submitApplication(array $applicationData): void
{
$response = $this->request('POST', 'applications', [
'json' => $applicationData,
]);
// A conflict means the submission is already known, which is the normal answer to a
// double-clicked submit button and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'applications');
} }
/** /**
@@ -199,6 +217,30 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function request(string $method, string $uri, array $options = []): ResponseInterface private function request(string $method, string $uri, array $options = []): ResponseInterface
{ {
$response = $this->sendRequest($method, $uri, $options);
if (false === in_array($this->readStatusCode($response, $method, $uri), self::UNAUTHORIZED_STATUS_CODES, true)) {
return $response;
}
// The token outlives a single PHP request, so one the API has stopped accepting - after
// a MyEP restart or a revoked client - would keep failing every call for the rest of its
// cached lifetime unless it is actively discarded here.
$this->logWarning(sprintf('MyEP API rejected the cached access token for %s %s, re-authenticating once', $method, $uri));
$this->discardAccessToken();
return $this->sendRequest($method, $uri, $options);
}
/**
* @throws ApiException
*/
private function sendRequest(string $method, string $uri, array $options): ResponseInterface
{
// Resolved here rather than baked into the client's default options so that the shared
// client survives a token change, and so the retry above picks up the fresh token.
$options['auth_bearer'] = $this->getAccessToken($this->getConfiguration())->getToken();
try { try {
return $this->getHttpClient()->request($method, $uri, $options); return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) { } catch (TransportExceptionInterface $e) {
@@ -215,13 +257,7 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void
{ {
try { $statusCode = $this->readStatusCode($response, $method, $uri);
$statusCode = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
if (true === in_array($statusCode, $expectedStatusCodes, true)) { if (true === in_array($statusCode, $expectedStatusCodes, true)) {
return; return;
@@ -233,6 +269,39 @@ class ApiClient implements LoggerAwareInterface
throw new ApiException($message, $statusCode); throw new ApiException($message, $statusCode);
} }
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request. Symfony keeps the status on the response,
* so calling this twice costs nothing.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reads and discards a body nobody else is going to look at. Destroying an unconsumed
* response cancels the transfer, which closes the connection instead of returning it to
* the pool, so the next call would have to handshake again.
*/
private function drainResponse(ResponseInterface $response): void
{
try {
$response->getContent(false);
} catch (TransportExceptionInterface $e) {
// The status has already been asserted, so a failure here costs nothing but the
// connection reuse this method exists for.
}
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -253,13 +322,13 @@ class ApiClient implements LoggerAwareInterface
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
if (null !== static::$httpClient) {
return static::$httpClient;
}
$config = $this->getConfiguration(); $config = $this->getConfiguration();
static::$accessToken = $this->getAccessToken($config); $options = [];
$options = [
'auth_bearer' => static::$accessToken->getToken(),
];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s), // Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in // so a stalled API pins a PHP worker for a minute per request. The defaults live in
@@ -277,7 +346,7 @@ class ApiClient implements LoggerAwareInterface
$options['max_duration'] = $maxDuration; $options['max_duration'] = $maxDuration;
} }
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options); return static::$httpClient = HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options);
} }
/** /**
@@ -312,7 +381,7 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function getAccessToken(array $config): AccessToken private function getAccessToken(array $config): AccessToken
{ {
if (null !== static::$accessToken && false === static::$accessToken->hasExpired()) { if (true === $this->isTokenUsable(static::$accessToken)) {
return static::$accessToken; return static::$accessToken;
} }
@@ -321,7 +390,7 @@ class ApiClient implements LoggerAwareInterface
$cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER); $cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
if (is_array($cachedToken) && isset($cachedToken['access_token'])) { if (is_array($cachedToken) && isset($cachedToken['access_token'])) {
$token = new AccessToken($cachedToken); $token = new AccessToken($cachedToken);
if (false === $token->hasExpired()) { if (true === $this->isTokenUsable($token)) {
static::$accessToken = $token; static::$accessToken = $token;
return static::$accessToken; return static::$accessToken;
@@ -329,7 +398,7 @@ class ApiClient implements LoggerAwareInterface
} }
try { try {
static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials'); static::$accessToken = $this->normalizeToken($this->getProvider($config)->getAccessToken('client_credentials'));
$this->cacheAccessToken(static::$accessToken); $this->cacheAccessToken(static::$accessToken);
} catch (IdentityProviderException $e) { } catch (IdentityProviderException $e) {
$message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage()); $message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage());
@@ -348,16 +417,53 @@ class ApiClient implements LoggerAwareInterface
return static::$accessToken; return static::$accessToken;
} }
private function cacheAccessToken(AccessToken $token): void /**
* AccessToken::hasExpired() throws for a token that reports no expiry, so it cannot be used
* as the check - a token without one is simply treated as unusable and replaced. The same
* margin as the cache lifetime is applied on read as well, so a token cannot lapse between
* this check and the API call that uses it, whichever node wrote the cache entry.
*/
private function isTokenUsable(?AccessToken $token): bool
{ {
if (null === $token) {
return false;
}
$expires = $token->getExpires(); $expires = $token->getExpires();
if (null === $expires) { if (null === $expires) {
$lifetime = self::ACCESS_TOKEN_DEFAULT_TTL; return false;
} else {
$lifetime = $expires - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN;
} }
return $expires - self::ACCESS_TOKEN_EXPIRY_MARGIN > time();
}
/**
* A token that reports no expiry cannot be validated on read - isTokenUsable() rejects it
* and AccessToken::hasExpired() throws for it - so it is given the fallback lifetime here,
* once, and the rest of the class can treat every token the same way.
*/
private function normalizeToken(AccessToken $token): AccessToken
{
if (null !== $token->getExpires()) {
return $token;
}
return new AccessToken($token->jsonSerialize() + ['expires_in' => self::ACCESS_TOKEN_DEFAULT_TTL]);
}
private function discardAccessToken(): void
{
static::$accessToken = null;
$this->getCache()->remove(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
}
private function cacheAccessToken(AccessToken $token): void
{
// normalizeToken() has already given a token without a reported expiry the fallback
// lifetime, so there is always a real expiry to subtract the margin from here.
$lifetime = $token->getExpires() - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN;
if ($lifetime <= 0) { if ($lifetime <= 0) {
return; return;
} }
@@ -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',
@@ -40,7 +40,19 @@ bpnConnectApiTimeout = 5
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited) # cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
bpnConnectApiMaxDuration = 15 bpnConnectApiMaxDuration = 15
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source # cat=EpTeamAPI; type=string; label=MyE&P Team base URL (https, e.g. https://team.ep-reisen.de) - receives application form submissions
epTeamApiBaseUrl =
# cat=EpTeamAPI; type=string; label=MyE&P Team application form API key (sent as X-Api-Key)
epTeamApiKey =
# cat=EpTeamAPI; type=int; label=MyE&P Team API inactivity timeout in seconds (0 = PHP default_socket_timeout)
epTeamApiTimeout = 5
# cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited)
epTeamApiMaxDuration = 15
# 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
@@ -63,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,
@@ -34,6 +34,12 @@ abstract class AbstractMappedFinisher extends AbstractFinisher
return $mappings; return $mappings;
} }
/**
* The mapped values, keyed by the remote name the receiving side expects. Values pass through
* normalizeValue() just like the labeled ones do, so a date element arrives as a Y-m-d string
* instead of a serialized DateTime. A value that does not survive normalization keeps its key:
* the remote names make up the request shape and dropping one would change it.
*/
protected function buildPayload(): array protected function buildPayload(): array
{ {
$values = []; $values = [];
@@ -44,7 +50,7 @@ abstract class AbstractMappedFinisher extends AbstractFinisher
continue; continue;
} }
$values[$remote] = $formValues[$local]; $values[$remote] = $this->normalizeValue($formValues[$local], $local);
} }
return $values; return $values;
@@ -2,7 +2,7 @@
namespace EP\EpTheme\Form; namespace EP\EpTheme\Form;
use EP\EpProducts\MyEP\ApiClient; use EP\EpProducts\EpTeam\ApiClient;
use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -26,7 +26,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
try { try {
$payload = $this->buildPayload(); $payload = $this->buildPayload();
if ([] === $payload) { if ([] === $payload) {
$this->logWarning('Application API finisher skipped: the mapping produced no values.'); $this->logWarning('Application form API finisher skipped: the mapping produced no values.');
return; return;
} }
@@ -43,9 +43,9 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
$payload = array_merge($payload, $this->resolvePageContext()); $payload = array_merge($payload, $this->resolvePageContext());
$payload['details'] = $this->buildLabeledValues(); $payload['details'] = $this->buildLabeledValues();
$this->apiClient->submitApplication($payload); $this->apiClient->submitApplicationForm($payload);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logWarning('Application API finisher request failed.', [ $this->logWarning('Application form API finisher request failed.', [
'exception' => $e, 'exception' => $e,
]); ]);
} }
@@ -72,7 +72,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// An unroutable page must not cost the submission, so the uid is sent without a URL. // An unroutable page must not cost the submission, so the uid is sent without a URL.
$this->logWarning('Application API finisher could not resolve the page URL.', [ $this->logWarning('Application form API finisher could not resolve the page URL.', [
'pageUid' => $pageUid, 'pageUid' => $pageUid,
'exception' => $e, 'exception' => $e,
]); ]);
@@ -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
@@ -1,17 +1,23 @@
config.tx_replacer { config.tx_replacer {
enable_regex = 1 enable_regex = 1
# The quote character is captured rather than matched literally, because the # Only images go to the CDN. The theme's own css/js/fonts are served by the production
# @font-face block in Page/Main.typoscript writes its url() values with single # host: they are already minified by Webpack, gzipped by mod_deflate and content-hashed
# quotes - anchoring on `"` alone would skip every font. # for a year of immutable browser caching, and keeping them on the page's own origin
# typo3conf is scoped to ep_theme/Resources/Public on purpose: it keeps other # means they arrive over the connection the browser has already opened rather than
# extensions, Configuration/ and Resources/Private/ out of the CDN. # costing a second DNS lookup and TLS handshake on the critical path.
# The icon sprite is excluded: SVG <use href="...#icon"> may only reference a #
# document from the same origin - browsers block an external sprite on the CDN # It also keeps them clear of the pull zone's habit of occasionally wedging a single
# host outright, and no CORS header on bunny.net changes that. It has to stay # cache key (10.0s -> 504 errorcode 106 until that key is purged, while the origin
# on the origin domain or every icon renders empty. # serves the same bytes in under 100ms). For an image that degrades one element; for
# js/main.<hash>.js it would take every visitor's JavaScript down.
#
# typo3temp is scoped to generated images on purpose - assets/compressed holds other
# extensions' concatenated css/js, which belongs on the origin for the same reason.
# The quote character is captured rather than matched literally, so url() values
# written with single quotes are rewritten too.
search { search {
10 = /(["'])\/?(fileadmin|typo3temp|uploads|typo3conf\/ext\/ep_theme\/Resources\/Public(?!\/images\/icons_sprite\.svg))/ 10 = /(["'])\/?(fileadmin|uploads|typo3temp\/assets\/(?:_processed_|images))/
} }
replace { replace {
@@ -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
}
}
@@ -31,6 +31,15 @@ page {
40.value = "> 40.value = ">
} }
# Every image on the page comes from the CDN host (see Extensions/Replacer.typoscript),
# so a cold visitor pays a full DNS + TCP + TLS handshake - measured at 50-70ms - before
# the first one can start downloading. `preconnect` gets that out of the way while the
# HTML is still parsing. No `crossorigin`: images are fetched without CORS, and an
# anonymous-vs-CORS mismatch would open a second connection instead of reusing this one.
# Fonts, which would need it, are served by this host now.
headerData.100 = TEXT
headerData.100.value = <link rel="preconnect" href="https://cdn.ep-reisen.de">
headerData.333 = TEXT headerData.333 = TEXT
headerData.333.value ( headerData.333.value (
<link rel="shortcut icon" type="image/x-icon" href="/typo3conf/ext/ep_theme/Resources/Public/images/favicon.ico"> <link rel="shortcut icon" type="image/x-icon" href="/typo3conf/ext/ep_theme/Resources/Public/images/favicon.ico">
@@ -57,13 +66,21 @@ page {
<link rel="icon" type="image/png" href="/typo3conf/ext/ep_theme/Resources/Public/images/favicon-196x196.png" sizes="196x196"> <link rel="icon" type="image/png" href="/typo3conf/ext/ep_theme/Resources/Public/images/favicon-196x196.png" sizes="196x196">
) )
# disableCompression keeps ResourceCompressor's hands off the Encore entries: Webpack
# already minifies them, and copying them to typo3temp/assets/compressed only replaces the
# content hash with a name derived from md5(abspath + mtime + size), which changes on every
# deploy even when the bytes do not - defeating the immutable caching the hash exists for.
# RequestHandler turns this into `compress => false`, which PageRendererHooks forwards to
# typo3_encore's TagRenderer (whose own default it would otherwise override).
includeCSS { includeCSS {
main = typo3_encore:EpThemeConfig:main main = typo3_encore:EpThemeConfig:main
main.disableCompression = 1
} }
includeJSFooter { includeJSFooter {
main = typo3_encore:EpThemeConfig:main main = typo3_encore:EpThemeConfig:main
main.defer = 1 main.defer = 1
main.disableCompression = 1
} }
10 = FLUIDTEMPLATE 10 = FLUIDTEMPLATE
@@ -150,45 +167,6 @@ page {
settings < plugin.tx_eptheme.settings settings < plugin.tx_eptheme.settings
} }
CSS_inlineStyle (
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local(''),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-300.woff2') format('woff2'),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-300.woff') format('woff');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(''),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-regular.woff2') format('woff2'),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-regular.woff') format('woff');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local(''),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-700.woff2') format('woff2'),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-700.woff') format('woff');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 900;
font-display: swap;
src: local(''),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-900.woff2') format('woff2'),
url('/typo3conf/ext/ep_theme/Resources/Public/fonts/lato-v17-latin-900.woff') format('woff');
}
)
headerData.990 = TEXT headerData.990 = TEXT
headerData.990 { headerData.990 {
value = <script src="{$plugin.tx_eptheme.settings.cookieFirstScriptUrl}"></script> value = <script src="{$plugin.tx_eptheme.settings.cookieFirstScriptUrl}"></script>
@@ -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() {
@@ -1,5 +1,16 @@
import { Controller } from '@hotwired/stimulus' import { Controller } from '@hotwired/stimulus'
import L from 'leaflet' import L from 'leaflet'
import iconUrl from 'leaflet/dist/images/marker-icon.png'
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'
import shadowUrl from 'leaflet/dist/images/marker-shadow.png'
// Icon.Default finds its images by reading the url() off .leaflet-default-icon-path and
// stripping a literal `marker-icon.png` off the end - which a content-hashed filename never
// matches, and the `link[href$="leaflet.css"]` fallback does not exist either because the
// CSS is inlined into the bundle. Dropping the _getIconUrl override falls back to plain
// option lookup, so the URLs Webpack emitted are used as they are.
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl })
export default class extends Controller { export default class extends Controller {
@@ -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' ||
@@ -1,5 +1,11 @@
@use "sass:meta"; @use "sass:meta";
// Outside the cascade layers on purpose: @font-face is invalid inside @layer, which is why
// _base.scss leaves base/fonts out. Loading it here lets Webpack resolve the url()s to
// content-hashed filenames, and replaces the hand-maintained @font-face block that used to
// live in ep_theme Page/Main.typoscript.
@include meta.load-css("base/fonts");
@include meta.load-css("tailwindcss/base"); @include meta.load-css("tailwindcss/base");
@include meta.load-css("base"); @include meta.load-css("base");
@@ -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"
@@ -0,0 +1,14 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
<formvh:renderRenderable renderable="{element}">
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
<f:form.textfield
type="date"
property="{element.identifier}"
id="{element.uniqueIdentifier}"
class="form-field"
errorClass="form-field--has-error"
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
/>
</f:render>
</formvh:renderRenderable>
</html>
@@ -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>
@@ -53,7 +53,7 @@
data-action="click->collapse#toggle"> data-action="click->collapse#toggle">
<use href="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/icons_sprite.svg')}#icon-calendar"></use> <use href="{f:uri.image(src: 'EXT:ep_theme/Resources/Public/images/icons_sprite.svg')}#icon-calendar"></use>
</svg> </svg>
<div class="hidden absolute z-50 top-100 mt-2 left-0 min-w-full bg-white p-4 shadow-md rounded" <div class="hidden absolute z-100 top-100 mt-2 left-0 min-w-full bg-white p-4 shadow-md rounded"
data-collapse-target="container"> data-collapse-target="container">
<div class="flex justify-between divide-x divide-zinc-200"> <div class="flex justify-between divide-x divide-zinc-200">
<div class="pr-2"> <div class="pr-2">
+15 -8
View File
@@ -12,7 +12,10 @@ Encore
.addEntry('main', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/main.js') .addEntry('main', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/js/main.js')
.addStyleEntry('rte', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/rte.scss') .addStyleEntry('rte', './public/typo3conf/ext/ep_theme/Resources/Private/Assets/scss/rte.scss')
.splitEntryChunks() .splitEntryChunks()
.enableSingleRuntimeChunk() // There is only one JS entry, so a shared runtime chunk buys nothing and costs a separate
// 1.6KB request that every other bundle hard-depends on. Inlining it into main.js removes
// both, and stops the `rte` style entry from dragging a JS file in behind it.
.disableSingleRuntimeChunk()
.enableSassLoader(options => { .enableSassLoader(options => {
// Opt into the modern Sass JS API (sass-loader defaults to the legacy one, // Opt into the modern Sass JS API (sass-loader defaults to the legacy one,
// which Dart Sass 2.0 removes). Encore still passes `outputStyle`, which // which Dart Sass 2.0 removes). Encore still passes `outputStyle`, which
@@ -37,20 +40,24 @@ Encore
} }
}) })
.copyFiles([ .copyFiles([
{ from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/fonts', to: 'fonts/[name].[ext]' },
{ from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images', to: 'images/[name].[ext]' }, { from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images', to: 'images/[name].[ext]' },
{ from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images/icons', to: 'images/icons/[name].[ext]' }, { from: './public/typo3conf/ext/ep_theme/Resources/Private/Assets/images/icons', to: 'images/icons/[name].[ext]' },
{ from: './node_modules/leaflet/dist/images', to: 'images/[name].[ext]' },
]) ])
// The content hash is what lets .htaccess serve these as `immutable` for a year: a new
// build is a new URL, so a browser never has to revalidate one. It also replaces TYPO3's
// `?<mtime>` query buster, which changed on every deploy even when the bytes did not.
// `rte` stays unhashed - Configuration/RTE/*.yaml reference rte.css by literal path.
.configureFilenames({ .configureFilenames({
js: 'js/[name].js', js: Encore.isProduction() ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
css: 'css/[name].css', css: (pathData) => Encore.isProduction() && pathData.chunk.name !== 'rte'
? 'css/[name].[contenthash:8].css'
: 'css/[name].css',
}) })
.configureImageRule({ .configureImageRule({
filename: 'images/[name][ext]', filename: 'images/[name].[contenthash:8][ext]',
}) })
.configureFontRule({ .configureFontRule({
filename: 'fonts/[name][ext]', filename: 'fonts/[name].[contenthash:8][ext]',
}) })
.configureBabelPresetEnv((config) => { .configureBabelPresetEnv((config) => {
config.shippedProposals = true config.shippedProposals = true
@@ -59,7 +66,7 @@ Encore
const EpThemeConfig = Encore.getWebpackConfig(); const EpThemeConfig = Encore.getWebpackConfig();
EpThemeConfig.name = 'EpThemeConfig'; EpThemeConfig.name = 'EpThemeConfig';
EpThemeConfig.output.chunkFilename = 'js/[name].[chunkhash:8].js'; EpThemeConfig.output.chunkFilename = 'js/[name].[contenthash:8].js';
Encore.reset(); Encore.reset();