Compare commits
25
Commits
4095c4a068
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f46d0acf | ||
|
|
4279669aa3 | ||
|
|
aa3d81102e | ||
|
|
739a4c49f7 | ||
|
|
88e0488b49 | ||
|
|
f57671b4d4 | ||
|
|
313fb151bc | ||
|
|
6350742084 | ||
|
|
ef7d81fe99 | ||
|
|
0477589a7a | ||
|
|
38962845d3 | ||
|
|
c7f43ad02c | ||
|
|
1111c180f8 | ||
|
|
69f9b42757 | ||
|
|
5bb4b6254a | ||
|
|
32b70f9b70 | ||
|
|
7839b97e63 | ||
|
|
416ac5c13d | ||
|
|
992ec19c33 | ||
|
|
57b09624f4 | ||
|
|
81724c818b | ||
|
|
b4a9156df6 | ||
|
|
41d188a8cc | ||
|
|
c6ab02305b | ||
|
|
306c6b809f |
No files matched your search
@@ -20,3 +20,4 @@ public/typo3conf/l10n
|
||||
public/typo3temp
|
||||
public/uploads
|
||||
var
|
||||
http-client.private.env.json
|
||||
@@ -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 https://ep-reisen.ddev.site/api/product
|
||||
?product=SSTMR
|
||||
&hotel=SSTGR
|
||||
&key=abcabc123
|
||||
GET {{host}}/api/product?product=3VIIN&hotel=VLTS
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("200 with key", function () {
|
||||
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### GET hotel data for myep
|
||||
GET https://ep-reisen.ddev.site/api/hotel
|
||||
?hotel=DPWGRP
|
||||
&key=abcabc123
|
||||
GET {{host}}/api/hotel?hotel=DPWGRP
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
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);
|
||||
});
|
||||
%}
|
||||
|
||||
###
|
||||
@@ -0,0 +1,141 @@
|
||||
# Chatbot content feed (see .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md)
|
||||
#
|
||||
# Pick the "local" environment in PhpStorm and run the whole file top to bottom: the manifest
|
||||
# request stores uids and a content URL that the later requests use.
|
||||
#
|
||||
# Needs, locally:
|
||||
# - http-client.private.env.json with {"local": {"cmsApiKey": "<your key>"}} (gitignored)
|
||||
# - the same key in TYPO3, e.g.
|
||||
# ddev exec vendor/bin/typo3cms configuration:set EXTENSIONS/ep_products/cmsApiKey <your key>
|
||||
# plus contentFeedSites etc. - or open and save the ep_products extension configuration once
|
||||
# in the backend, which writes all the defaults.
|
||||
#
|
||||
# The kill switch (contentFeedEnabled = 0) cannot be toggled from here; with it off, every keyed
|
||||
# request below answers 503.
|
||||
|
||||
### Manifest without a key is refused
|
||||
GET {{host}}/api/content/manifest
|
||||
|
||||
> {%
|
||||
client.test("403 without key", function () {
|
||||
client.assert(response.status === 403, "expected 403, got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### Manifest
|
||||
GET {{host}}/api/content/manifest
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("manifest is complete and well-formed", function () {
|
||||
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||
client.assert(response.body.items.length > 0, "manifest is empty");
|
||||
client.assert(response.body.sites.length > 0, "no sites");
|
||||
client.assert(
|
||||
response.body.items.every(function (item) { return item.type && item.uid && item.modified; }),
|
||||
"an item lacks type, uid or modified"
|
||||
);
|
||||
});
|
||||
|
||||
var uidsOf = function (type, limit) {
|
||||
return response.body.items
|
||||
.filter(function (item) { return item.type === type; })
|
||||
.slice(0, limit)
|
||||
.map(function (item) { return item.uid; })
|
||||
.join(",");
|
||||
};
|
||||
|
||||
client.global.set("productUids", uidsOf("product", 5));
|
||||
client.global.set("hotelUids", uidsOf("hotel", 5));
|
||||
client.global.set("regionUids", uidsOf("region", 5));
|
||||
client.global.set("newsUids", uidsOf("news", 3));
|
||||
|
||||
var page = response.body.items.filter(function (item) { return item.type === "page" && item.contentUrl; })[0];
|
||||
client.global.set("contentUrl", page.contentUrl);
|
||||
|
||||
var tooMany = [];
|
||||
for (var i = 1; i <= 101; i++) { tooMany.push(i); }
|
||||
client.global.set("tooManyUids", tooMany.join(","));
|
||||
%}
|
||||
|
||||
### Products (with FAQs)
|
||||
GET {{host}}/api/content/records?type=product&uids={{productUids}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("every requested product comes back, links resolved", function () {
|
||||
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||
client.assert(response.body.items.length === client.global.get("productUids").split(",").length, "products missing");
|
||||
client.assert(JSON.stringify(response.body).indexOf("t3://") === -1, "unresolved t3:// link");
|
||||
});
|
||||
%}
|
||||
|
||||
### Hotels (codes include matchcode aliases)
|
||||
GET {{host}}/api/content/records?type=hotel&uids={{hotelUids}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("hotels carry their codes", function () {
|
||||
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||
client.assert(response.body.items.every(function (hotel) { return Array.isArray(hotel.codes); }), "hotel without codes");
|
||||
});
|
||||
%}
|
||||
|
||||
### Regions
|
||||
GET {{host}}/api/content/records?type=region&uids={{regionUids}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
### Blog articles
|
||||
GET {{host}}/api/content/records?type=news&uids={{newsUids}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
### Unknown type is a 400
|
||||
GET {{host}}/api/content/records?type=page&uids=1
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("400 for unknown type", function () {
|
||||
client.assert(response.status === 400, "expected 400, got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### More than 100 uids is a 400
|
||||
GET {{host}}/api/content/records?type=product&uids={{tooManyUids}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("400 above 100 uids", function () {
|
||||
client.assert(response.status === 400, "expected 400, got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### Content-only render of the first page in the manifest
|
||||
GET {{contentUrl}}
|
||||
X-Api-Key: {{cmsApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("bare article, never cached by proxies", function () {
|
||||
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||
client.assert(response.body.indexOf("<article>") === 0, "not a bare <article> fragment");
|
||||
client.assert(response.headers.valueOf("Cache-Control") === "private, no-store", "unexpected Cache-Control");
|
||||
});
|
||||
%}
|
||||
|
||||
### Content-only render without a key is a 404 (the type must not be discoverable)
|
||||
GET {{contentUrl}}
|
||||
|
||||
> {%
|
||||
client.test("404 without key", function () {
|
||||
client.assert(response.status === 404, "expected 404, got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### Content-only render with a wrong key is a 404
|
||||
GET {{contentUrl}}
|
||||
X-Api-Key: definitely-wrong
|
||||
|
||||
> {%
|
||||
client.test("404 with wrong key", function () {
|
||||
client.assert(response.status === 404, "expected 404, got " + response.status);
|
||||
});
|
||||
%}
|
||||
+4
-1
@@ -70,7 +70,10 @@ host('prod')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('rsync_src', __DIR__)
|
||||
->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', [
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"local": {
|
||||
"host": "https://ep-reisen.ddev.site"
|
||||
},
|
||||
"prod": {
|
||||
"host": "https://www.ep-reisen.de"
|
||||
}
|
||||
}
|
||||
@@ -285,9 +285,6 @@ AddDefaultCharset utf-8
|
||||
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(png|gif|jpe?g)$">
|
||||
Header append Vary Accept
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
### End: Cross Origin ###
|
||||
@@ -395,12 +392,6 @@ AddDefaultCharset utf-8
|
||||
# NOTE: Add your additional local storages here
|
||||
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) ####
|
||||
|
||||
# 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.
|
||||
$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
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] =
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['errorHandlerErrors'] & ~E_DEPRECATED & ~E_USER_DEPRECATED;
|
||||
|
||||
@@ -59,6 +59,7 @@ call_user_func(function() {
|
||||
'label' => 'Reiseart',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'items' => [
|
||||
['Keine Zuordnung', 0],
|
||||
@@ -74,6 +75,7 @@ call_user_func(function() {
|
||||
'label' => 'Angebot/Beispielangebot',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'items' => [
|
||||
['Keine Zuordnung', 0],
|
||||
|
||||
@@ -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\FilterService;
|
||||
use EP\EpProducts\Service\HotelDataService;
|
||||
use EP\EpProducts\Service\PickupOverviewService;
|
||||
use EP\EpProducts\Service\ProductDataService;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
|
||||
@@ -82,6 +83,11 @@ class ProductController extends ActionController
|
||||
*/
|
||||
private $infoboxRepository;
|
||||
|
||||
/**
|
||||
* @var PickupOverviewService
|
||||
*/
|
||||
private $pickupOverviewService;
|
||||
|
||||
/**
|
||||
* @param ProductRepository $productRepository
|
||||
* @param ProductDataService $productDataService
|
||||
@@ -91,6 +97,7 @@ class ProductController extends ActionController
|
||||
* @param DateService $dateService
|
||||
* @param FilterService $filterService
|
||||
* @param InfoboxRepository $infoboxRepository
|
||||
* @param PickupOverviewService $pickupOverviewService
|
||||
*/
|
||||
public function __construct(
|
||||
ProductRepository $productRepository,
|
||||
@@ -100,7 +107,8 @@ class ProductController extends ActionController
|
||||
ContingentDataService $contingentDataService,
|
||||
DateService $dateService,
|
||||
FilterService $filterService,
|
||||
InfoboxRepository $infoboxRepository
|
||||
InfoboxRepository $infoboxRepository,
|
||||
PickupOverviewService $pickupOverviewService
|
||||
) {
|
||||
$this->productRepository = $productRepository;
|
||||
$this->productDataService = $productDataService;
|
||||
@@ -110,6 +118,7 @@ class ProductController extends ActionController
|
||||
$this->dateService = $dateService;
|
||||
$this->filterService = $filterService;
|
||||
$this->infoboxRepository = $infoboxRepository;
|
||||
$this->pickupOverviewService = $pickupOverviewService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +205,7 @@ class ProductController extends ActionController
|
||||
'referringUrl' => $backlinkUrl,
|
||||
'backlinkLabel' => $backLinkLabel,
|
||||
'nonBookableInfo' => $nonBookableInfo,
|
||||
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
||||
*/
|
||||
protected $skipassIncluded = false;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $groupTravel = false;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
@@ -421,6 +426,22 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
||||
$this->skipassIncluded = $skipassIncluded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function getGroupTravel()
|
||||
{
|
||||
return $this->groupTravel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $groupTravel
|
||||
*/
|
||||
public function setGroupTravel($groupTravel)
|
||||
{
|
||||
$this->groupTravel = $groupTravel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace EP\EpProducts\Domain\Model;
|
||||
* 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\DomainObject\AbstractEntity;
|
||||
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
|
||||
@@ -1253,4 +1254,20 @@ class Hotel extends AbstractEntity implements TeaserInterface
|
||||
{
|
||||
$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;
|
||||
|
||||
use EP\EpProducts\Domain\Model\Product;
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
|
||||
class DateRepository extends AbstractRepository
|
||||
{
|
||||
@@ -18,4 +19,27 @@ class DateRepository extends AbstractRepository
|
||||
->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.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.group_travel as dateGroupTravel',
|
||||
'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.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.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded',
|
||||
'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.available as available', 'date.product_detail_page as productDetailPage',
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
use EP\EpProducts\ContentFeed\ContentFeedApi;
|
||||
use EP\EpProducts\Http\ApiKeyAuthenticator;
|
||||
use EP\EpProducts\Service\MyEpImageService;
|
||||
use EP\EpProducts\Utility\HotelIconUtility;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -40,18 +43,32 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
*/
|
||||
private $imageService;
|
||||
|
||||
/**
|
||||
* @var ContentFeedApi
|
||||
*/
|
||||
private $contentFeedApi;
|
||||
|
||||
/**
|
||||
* @var ApiKeyAuthenticator
|
||||
*/
|
||||
private $authenticator;
|
||||
|
||||
public function __construct(
|
||||
MyEpImageService $hotelImageService,
|
||||
MyEpImageService $productImageService,
|
||||
MyEpImageService $regionImageService,
|
||||
FileRepository $fileRepository,
|
||||
ImageService $imageService
|
||||
ImageService $imageService,
|
||||
ContentFeedApi $contentFeedApi,
|
||||
ApiKeyAuthenticator $authenticator
|
||||
) {
|
||||
$this->hotelImageService = $hotelImageService;
|
||||
$this->productImageService = $productImageService;
|
||||
$this->regionImageService = $regionImageService;
|
||||
$this->fileRepository = $fileRepository;
|
||||
$this->imageService = $imageService;
|
||||
$this->contentFeedApi = $contentFeedApi;
|
||||
$this->authenticator = $authenticator;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
@@ -66,17 +83,17 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
return $this->handleProductRequest($request);
|
||||
}
|
||||
|
||||
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
|
||||
return $this->contentFeedApi->handle($request);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// This is currently fake, we only check for existence. Since we are communicating
|
||||
// on the backchannel only it's not much of a problem.
|
||||
$apiKey = $request->getQueryParams()['key'] ?? null;
|
||||
|
||||
if (null === $apiKey) {
|
||||
return new JsonResponse(['message' => 'No api key provided'], 403);
|
||||
if (!$this->authenticator->isAuthorized($request)) {
|
||||
return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
|
||||
}
|
||||
|
||||
$hotelCode = $request->getQueryParams()['hotel'] ?? null;
|
||||
@@ -96,12 +113,8 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
|
||||
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// This is currently fake, we only check for existence. Since we are communicating
|
||||
// on the backchannel only it's not much of a problem.
|
||||
$apiKey = $request->getQueryParams()['key'] ?? null;
|
||||
|
||||
if (null === $apiKey) {
|
||||
return new JsonResponse(['message' => 'No api key provided'], 403);
|
||||
if (!$this->authenticator->isAuthorized($request)) {
|
||||
return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
|
||||
}
|
||||
|
||||
$productCode = $request->getQueryParams()['product'] ?? null;
|
||||
@@ -217,70 +230,6 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
|
||||
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
|
||||
|
||||
// map fields to icon ids and labels
|
||||
$icons = [];
|
||||
|
||||
if (($hotel['beds_count'] ?? 0) > 0) {
|
||||
$icons[] = [
|
||||
'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 [
|
||||
'name' => $hotel['name'],
|
||||
'code' => $hotel['code'],
|
||||
@@ -290,7 +239,7 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
'additional_information' => $hotel['additional_information'],
|
||||
'address' => $hotel['address'],
|
||||
'images' => $images,
|
||||
'icons' => $icons,
|
||||
'icons' => HotelIconUtility::getIcons($hotel),
|
||||
'region' => $this->fetchRegionData((int) $hotel['region']),
|
||||
];
|
||||
}
|
||||
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,22 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
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.
|
||||
@@ -286,6 +302,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
{
|
||||
$dateCount = 0;
|
||||
|
||||
// Selection groups are date-level, so any hotel row tells.
|
||||
$this->checkCustomerSelection(reset($rows));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$date = $this->createDateRecord($product, $row);
|
||||
|
||||
@@ -410,6 +429,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
$skiPassIncluded = (int) (count($includedSkiPass) > 0);
|
||||
$date['skipass_included'] = $skiPassIncluded;
|
||||
|
||||
$date['group_travel'] = (int) $this->isGroupTravel($row);
|
||||
|
||||
$servicesTransportation = $this->parseService($row['transportServices']);
|
||||
$discount = $this->determineDiscount($servicesTransportation);
|
||||
$date['discount'] = $discount;
|
||||
@@ -771,6 +792,49 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
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
|
||||
|
||||
@@ -230,6 +230,7 @@ class DateService implements SingletonInterface
|
||||
'summer' => $row['season'] === 's',
|
||||
'busIncluded' => (bool) $row['dateBusIncluded'],
|
||||
'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'],
|
||||
'groupTravel' => (bool) $row['dateGroupTravel'],
|
||||
'roomUid' => $row['roomUid'],
|
||||
'roomName' => $row['roomName'],
|
||||
'available' => $row['roomAvailable'] && $row['roomPrice'],
|
||||
@@ -320,6 +321,7 @@ class DateService implements SingletonInterface
|
||||
'nights' => $row['dateNights'],
|
||||
'busIncluded' => (bool) $row['dateBusIncluded'],
|
||||
'skiPassIncluded' => (bool) $row['dateSkiPassIncluded'],
|
||||
'groupTravel' => (bool) $row['dateGroupTravel'],
|
||||
'servicesIncluded' => unserialize($row['dateServicesIncluded'], ['allowed_classes' => false]),
|
||||
'servicesGeneral' => unserialize($row['dateServicesGeneral'], ['allowed_classes' => false]),
|
||||
'season' => $row['season'],
|
||||
|
||||
@@ -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,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',
|
||||
],
|
||||
],
|
||||
'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' => [
|
||||
'target' => \EP\EpProducts\Middleware\StagingEnvironmentMiddleware::class,
|
||||
'after' => [
|
||||
|
||||
+1
@@ -403,6 +403,7 @@ return [
|
||||
'label' => 'Produkte',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectMultipleSideBySide',
|
||||
'foreign_table' => 'tx_epproducts_domain_model_product',
|
||||
'size' => 10,
|
||||
|
||||
+10
-2
@@ -24,8 +24,8 @@ return [
|
||||
],
|
||||
'types' => [
|
||||
'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,
|
||||
services_general, courses_included, guide, pickups, discount, min_price, pseudo_price, available, min_pax,
|
||||
board_code, board_bus_pro_id, bus_included, bus_available, skipass_included, group_travel,
|
||||
services_included, services_general, courses_included, guide, pickups, discount, min_price, pseudo_price, available, min_pax,
|
||||
new'
|
||||
],
|
||||
],
|
||||
@@ -322,6 +322,14 @@ return [
|
||||
'default' => 0
|
||||
]
|
||||
],
|
||||
'group_travel' => [
|
||||
'exclude' => false,
|
||||
'label' => 'Gruppenreise',
|
||||
'config' => [
|
||||
'type' => 'check',
|
||||
'default' => 0
|
||||
]
|
||||
],
|
||||
'bus_price' => [
|
||||
'exclude' => false,
|
||||
'label' => 'Bus Preis',
|
||||
|
||||
@@ -75,3 +75,21 @@ productImportSftpHostKey =
|
||||
|
||||
# cat=SftpImport; type=int+; label=SFTP connection timeout in seconds
|
||||
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,6 +117,7 @@ CREATE TABLE tx_epproducts_domain_model_date
|
||||
bus_included 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,
|
||||
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_general text NOT NULL,
|
||||
|
||||
@@ -24,6 +24,7 @@ call_user_func(function() {
|
||||
'label' => 'Land',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'foreign_table' => 'tx_epproducts_domain_model_country',
|
||||
'foreign_table_where' => 'ORDER BY name',
|
||||
@@ -39,6 +40,7 @@ call_user_func(function() {
|
||||
'label' => 'Gebiet',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'foreign_table' => 'tx_epproducts_domain_model_region',
|
||||
'foreign_table_where' => 'ORDER BY name',
|
||||
@@ -106,6 +108,7 @@ call_user_func(function() {
|
||||
'label' => 'Badge',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'foreign_table' => 'tx_epproducts_domain_model_badge',
|
||||
'foreign_table_where' => 'ORDER BY title',
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ call_user_func(function() {
|
||||
'label' => 'Mitarbeiter',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'default' => 0,
|
||||
'renderType' => 'selectSingle',
|
||||
'foreign_table' => 'tx_epproducts_domain_model_teammember',
|
||||
'foreign_table_where' => 'ORDER BY name',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Chatbot content feed: the main content of a page, without header, navigation, footer or
|
||||
# assets. Fetched by MyE&P for pages the content manifest reports as changed; protected by
|
||||
# EP\EpProducts\Middleware\ContentPageTypeGuardMiddleware. See
|
||||
# .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §6.
|
||||
|
||||
# Plugins (the booking widget on product detail pages), forms, sub-page menus and teaser cards
|
||||
# are left out: MyE&P has trip data first-hand, and the pages and records the cards link to are
|
||||
# in the feed in their own right. The filter applies to top-level elements only; a plugin nested
|
||||
# in a grid or container element is still rendered by its parent.
|
||||
lib.contentOnly < lib.dynamicContent
|
||||
lib.contentOnly.20.select.where = colPos={register:colPos} AND CType NOT IN ({$plugin.tx_eptheme.settings.contentOnlyExcludedCTypes})
|
||||
|
||||
tx_eptheme_content_only = PAGE
|
||||
tx_eptheme_content_only {
|
||||
typeNum = 1730
|
||||
config {
|
||||
disableAllHeaderCode = 1
|
||||
disablePrefixComment = 1
|
||||
additionalHeaders.10.header = Content-Type: text/html; charset=utf-8
|
||||
additionalHeaders.20.header = X-Robots-Tag: noindex, nofollow, noarchive
|
||||
admPanel = 0
|
||||
debug = 0
|
||||
# Never written to disk: the type is key-protected, and a static file would be served
|
||||
# by .htaccess without that check ever running.
|
||||
tx_staticfilecache.disableCache = 1
|
||||
# Same reason for shared caches between us and the consumer: the TYPO3 page cache is
|
||||
# fine (the guard runs before it), a proxy storing a keyed response is not.
|
||||
sendCacheHeaders = 0
|
||||
additionalHeaders.30.header = Cache-Control: private, no-store
|
||||
}
|
||||
|
||||
10 = COA
|
||||
10 {
|
||||
wrap = <article>|</article>
|
||||
|
||||
10 = TEXT
|
||||
10.field = title
|
||||
10.htmlSpecialChars = 1
|
||||
10.wrap = <h1>|</h1>
|
||||
|
||||
# Page templates without a sidebar render colPos 0; the sidebar layouts render 3 (header),
|
||||
# 4 (content) and 2 (sidebar). colPos 1 holds teaser groups and is left out. The page
|
||||
# templates pass colPos in as data; here there is no template, so each copy sets it.
|
||||
# These must be copies (<), not references (=<): removing colPos.cObject on a reference
|
||||
# leaves the referenced cObject in place, and every column silently renders colPos 0.
|
||||
20 < lib.contentOnly
|
||||
20.5.colPos.cObject >
|
||||
20.5.colPos = 3
|
||||
|
||||
30 < lib.contentOnly
|
||||
30.5.colPos.cObject >
|
||||
30.5.colPos = 0
|
||||
|
||||
40 < lib.contentOnly
|
||||
40.5.colPos.cObject >
|
||||
40.5.colPos = 4
|
||||
|
||||
50 < lib.contentOnly
|
||||
50.5.colPos.cObject >
|
||||
50.5.colPos = 2
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,8 @@ plugin.tx_eptheme {
|
||||
xmlSitemapRootPageUid =
|
||||
# cat=eptheme/600/110; type=string; label=XML sitemap page UIDs to exclude
|
||||
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
|
||||
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-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-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" 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-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-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-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-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>
|
||||
@@ -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-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-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>
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 37 KiB |
+7
-1
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'sizer', 'container', 'button' ]
|
||||
static targets = [ 'sizer', 'container', 'button', 'bar' ]
|
||||
|
||||
static values = {
|
||||
offset: {
|
||||
@@ -33,6 +33,12 @@ export default class extends Controller {
|
||||
// Set initial expanded/collapsed status and button visibility
|
||||
this.expandedValue = this.contentHeight < this.maxHeight
|
||||
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() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<f:render partial="Disrupter/Outlines" section="Top1" arguments="{classes: '-translate-y-2 text-white'}"/>
|
||||
</f:if>
|
||||
<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:for each="{footernav}" as="menuheader">
|
||||
<div>
|
||||
@@ -32,7 +32,7 @@
|
||||
</f:if>
|
||||
</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
|
||||
</div>
|
||||
|
||||
@@ -184,10 +184,8 @@
|
||||
<f:format.raw>{totalRatings.jsonData->f:format.json()}</f:format.raw>
|
||||
</script>
|
||||
|
||||
<f:comment><!--
|
||||
<f:if condition="{site.identifier} == 'ep-reisen'">
|
||||
<f:render partial="Chatbot" arguments="{_all}"/>
|
||||
</f:if>
|
||||
--></f:comment>
|
||||
<f:if condition="{site.identifier} == 'ep-reisen'">
|
||||
<f:render partial="Chatbot" arguments="{_all}"/>
|
||||
</f:if>
|
||||
|
||||
</html>
|
||||
@@ -12,7 +12,11 @@
|
||||
errorClass="form-field--has-error"
|
||||
placeholder="Bitte die Zeichen aus der Grafik eingeben"
|
||||
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)}&now=0" alt="Captcha image" loading="lazy" onload="this.parentElement.classList.remove('captcha--reloading'); return false;"/>
|
||||
<f:if condition="{element.properties.showRefresh}">
|
||||
<button type="button"
|
||||
class="captcha__reload"
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
</h2>
|
||||
</f:else>
|
||||
</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()}
|
||||
</div>
|
||||
<f:if condition="{teasers}">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
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}">
|
||||
<h3>
|
||||
Busanreise
|
||||
@@ -11,6 +11,9 @@
|
||||
{journey.byBus -> f:format.html()}
|
||||
</div>
|
||||
</f:if>
|
||||
<f:if condition="{pickupsOverview.pickups}">
|
||||
<f:render partial="Product/Pickups" arguments="{pickupsOverview: pickupsOverview}"/>
|
||||
</f:if>
|
||||
<f:if condition="{journey.byCar}">
|
||||
<h3>
|
||||
Eigenanreise
|
||||
|
||||
@@ -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>
|
||||
@@ -42,7 +42,7 @@
|
||||
<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: '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:if>
|
||||
</div>
|
||||
@@ -158,11 +158,11 @@
|
||||
</f:section>
|
||||
|
||||
<f:section name="Journey">
|
||||
<f:if condition="{product.journey}">
|
||||
<f:if condition="{product.journey} || {pickupsOverview.pickups}">
|
||||
<div class="mb-4 hidden"
|
||||
data-tabs-target="tab"
|
||||
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>
|
||||
</f:if>
|
||||
</f:section>
|
||||
|
||||
Reference in new issue
Block a user