Compare commits

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:21:06 +02:00
fromme 88e0488b49 fix: adopt text orientation in page footer on mobile screens 2026-10-18 18:13:39 +02:00
fromme f57671b4d4 fix: single column in footer menu on mobile screens 2026-10-18 18:11:13 +02:00
fromme 313fb151bc fix: add missing customer code pattern 2026-10-13 17:09:06 +02:00
fromme 6350742084 feat: show pickups table below bus section on product detail pages 2026-10-13 16:10:23 +02:00
fromme ef7d81fe99 feat: remove the feature flag for the pickups table on product detail pages 2026-10-13 14:26:22 +02:00
fromme 0477589a7a feat: fall back to the travel code for dates without a customer selection 2026-10-13 14:26:17 +02:00
fromme 38962845d3 chore: cleanup htaccess 2026-10-08 17:34:15 +02:00
fromme c7f43ad02c feat: add a feature flag for the pickups table on product detail pages 2026-10-08 17:22:21 +02:00
fromme 1111c180f8 feat: hide pickups of group travel dates on product detail pages 2026-10-08 17:20:31 +02:00
fromme 69f9b42757 feat: flag group travel dates from the BusPro customer selection 2026-10-08 16:09:43 +02:00
fromme 5bb4b6254a chore: update http client config 2026-10-08 11:40:57 +02:00
fromme 32b70f9b70 feat: require the CMS API key on /api/hotel and /api/product 2026-10-08 11:37:57 +02:00
fromme 7839b97e63 chore: add a PhpStorm HTTP client suite for the chatbot content feed 2026-10-08 09:41:27 +02:00
fromme 416ac5c13d feat: add a kill switch for the chatbot content feed 2026-10-08 09:36:16 +02:00
fromme 992ec19c33 feat: add the key-protected content-only page type for the chatbot feed 2026-10-08 09:36:12 +02:00
fromme 57b09624f4 feat: serve chatbot content records at /api/content/records 2026-10-08 09:36:06 +02:00
fromme 81724c818b feat: serve the chatbot content manifest at /api/content/manifest 2026-10-08 09:36:01 +02:00
fromme b4a9156df6 feat: add the API key check and settings for the chatbot content feed 2026-10-08 09:35:55 +02:00
fromme 41d188a8cc feat: display pickups data on product detail page
addresses #869cgn2fm
2026-10-07 17:01:13 +02:00
fromme c6ab02305b fix: load captcha image in Chrome on initial page load 2026-10-05 12:01:21 +02:00
fromme 306c6b809f feat: show hotel icons on hotel and product detail pages
addresses #86965w970
2026-10-01 14:17:28 +02:00
fromme 4095c4a068 fix: stop malformed captcha requests from poisoning the page cache 2026-09-30 15:24:50 +02:00
fromme b41aeb7a25 fix: carry pickup times as local clock readings, not instants 2026-09-30 14:57:46 +02:00
fromme c2dc88b8ab fix: match API whitespace handling and skip matchcodes of hidden hotels 2026-09-30 14:57:39 +02:00
fromme 27832d6548 fix: stop truncating imported prices to whole euros 2026-09-30 14:57:20 +02:00
fromme b510299032 feat: import products from the MyE&P export API 2026-09-30 14:57:13 +02:00
fromme 1b6f480bee feat: read the BusPro XML export through XmlProductReader 2026-09-30 14:57:07 +02:00
fromme b9851943d0 fix: key imported services by BusPro id and match selection groups by id 2026-09-30 14:56:50 +02:00
fromme d338529afc chore: cleanup .htaccess 2026-09-26 10:05:12 +02:00
fromme e3099bac5f feat: preconnect to the CDN host 2026-09-26 09:53:58 +02:00
fromme 322a59b31c feat: offer brotli for the assets the webhost now serves itself 2026-09-26 09:40:11 +02:00
fromme e96dd0f06d fix: resolve leaflet marker images through webpack 2026-09-26 09:35:58 +02:00
fromme f10d784a5c fix: serve theme assets from the production host, keep images on the CDN 2026-09-25 17:35:07 +02:00
fromme b5d0c484c0 fix: add missing template for date fields 2026-09-25 14:49:39 +02:00
fromme d8bb376424 fix: send form date values as YYYY-MM-DD strings to epteam api 2026-09-25 14:46:44 +02:00
fromme 38d575cbe9 fix: point the application form finisher at epteam 2026-09-23 09:26:12 +02:00
fromme f9e8704046 fix: datepicker obscured by logo in header searchbar 2026-09-23 09:24:25 +02:00
fromme 49547ecda3 fix: reuse the MyEP api connection instead of a client per call 2026-09-22 16:49:44 +02:00
fromme eb096bd8b5 chore: update project dependencies 2026-09-22 15:49:34 +02:00
fromme b3541b21fb feat: modernize fe tooling and drop weight 2026-09-22 15:29:34 +02:00
fromme 93ed5c6962 fix: harden encoded filter settings against malformed and stale input 2026-09-22 14:49:04 +02:00
fromme 942724fdd1 feat: derive room type from occupancy instead of a mapping table 2026-09-22 14:47:04 +02:00
frommeandClaude Opus 5 e52f7dcff8 feat: post application forms to MyEP with label-keyed extra values
Forms built in the backend form editor use generated element identifiers
(text-1, multicheckbox-2, ...) that carry no meaning and cannot be renamed
without breaking the other finishers configured on the form. ApplicationApiFinisher
therefore sends two halves: a small mapped core (firstName, lastName, email, ...)
that a developer configures per form and MyEP can rely on, plus every remaining
submitted value keyed by the element label for a human to read.

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 13:42:07 +02:00
fromme 30e531497d fix: default Geoapify base url to Europe 2026-09-22 10:54:41 +02:00
fromme 2cb37e760a fix: serve svg sprite locally for same-origin restrictions 2026-09-22 10:35:58 +02:00
fromme 3ff3251419 feat: integrate with Geaoapify for maps tiles 2026-09-22 10:25:20 +02:00
fromme abcd39107b fix: serve css/js via cdn 2026-09-22 09:32:15 +02:00
fromme 599e3c7ee2 fix: accept 409 from the MyEP contact form endpoint 2026-09-22 08:29:58 +02:00
fromme 6ff308015a chore: optimize deployer config 2026-09-15 11:38:56 +02:00
fromme 6e0479bf22 fix: reduce timeout values for snowreport import 2026-09-15 11:28:14 +02:00
fromme fb8fddf669 fix: make snowreport import type-safe and drop wget shell-out 2026-09-15 11:14:33 +02:00
fromme f745c7b683 chore: exclude temp dir from repository 2026-09-15 11:08:49 +02:00
fromme 9b3550ec40 feat: migrate to native lazy loading of images 2026-09-12 12:08:09 +02:00
fromme d9943619f6 chore: cleanup 2026-09-12 12:04:52 +02:00
fromme e33b57c9f1 feat: disable chatbot in page footer 2026-09-12 12:04:33 +02:00
fromme dc594efee1 chore: remove obsolete vue component 2026-09-12 11:52:46 +02:00
fromme 432d8da350 chore: update redis config 2026-09-12 11:31:29 +02:00
fromme 5b338eeab6 chore: align redis config with prod 2026-09-12 11:27:08 +02:00
fromme 05ceae5100 fix: simplify generated captchas to improve ux 2026-09-12 11:22:49 +02:00
fromme f005272242 chore: align php version with prod 2026-09-10 13:51:37 +02:00
fromme 7d856d1031 chore: align mariadb version with prod 2026-09-10 13:50:26 +02:00
fromme f9e94231a7 chore: update project dependencies 2026-09-10 12:54:35 +02:00
fromme 0a3e8f59cb fix: repair broken backend search and improve copy traceability 2026-09-10 12:52:55 +02:00
fromme a01d1ba0c5 chore: drop dead description column from product table 2026-09-10 11:50:38 +02:00
fromme 6828e49663 fix: declare importer-written date columns as passthrough 2026-09-10 11:50:38 +02:00
fromme 46001a02ed fix: add TCA default 0 to relation selects for MySQL strict mode 2026-09-10 10:17:10 +02:00
fromme 5e39e39b90 chore: finalize deployer config to new target host 2026-09-10 09:01:22 +02:00
fromme 8dad782603 feat: integrate with redis for improved performance 2026-09-08 18:27:00 +02:00
fromme 0e6a65dfa0 feat: integrate with bunny.net cdn for images 2026-09-05 10:47:00 +02:00
fromme 8f421da599 fix: prevent empty buttons in link panel content element 2026-09-03 15:51:38 +02:00
fromme 39fb85744d feat: replace secondary theme color for snowbreak subsite 2026-09-03 15:43:49 +02:00
fromme 07b42c9334 feat: import buspro xml files from remote sftp source 2026-09-03 10:02:36 +02:00
fromme e27b376285 chore: adjust deployer config for new Hetzner host 2026-09-03 08:18:46 +02:00
fromme 20e56aab1f feat: disable page header and breadcrumb in concept detail plugin 2026-09-01 11:45:07 +02:00
fromme d7219ec341 feat: remove date constraints from price calendar 2026-08-26 13:33:10 +02:00
fromme 0bdc6c873b feat: explicit icon label map for accommodation booking api 2026-08-20 14:43:09 +02:00
fromme b502127e2e fix: correct order of flushing caches during deployment 2026-08-20 12:12:18 +02:00
fromme baf7b5d94a feat: improved caching of api-backed content 2026-08-20 12:11:58 +02:00
fromme 87e6857bff chore: refactor and cleanup template 2026-08-19 17:50:41 +02:00
fromme 47de6027c8 fix: hide groups price calendar on winter products 2026-08-19 17:34:27 +02:00
fromme 3178eee8a0 feat: remove obsolete buttons/links from product detail page 2026-08-19 17:03:29 +02:00
fromme 229c675e4d feat: remove obsolete plugins for hotel groups prices 2026-08-19 14:53:22 +02:00
fromme 22072b4a25 feat: remove obsolete fields from backend form for hotel groups prices 2026-08-17 15:21:01 +02:00
fromme d809d747b7 chore: update frontend tooling 2026-08-17 08:40:41 +02:00
fromme 9fc490fabf fix: use currency value provided by api response for the calendar 2026-08-17 08:40:41 +02:00
fromme 5396ab941b feat: replace button labels 2026-08-17 08:40:41 +02:00
fromme e8154ebba1 fix: remove exstraneous border below tables 2026-08-17 08:40:41 +02:00
Björn Fromme 7342db4afa feat: groups bookings calendar 2026-08-17 08:40:41 +02:00
245 changed files with 9891 additions and 7882 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
name: redis
repository: ddev/ddev-redis
version: v2.2.0
install_date: "2026-09-08T18:12:40+02:00"
project_files:
- docker-compose.redis.yaml
- redis/scripts/settings.ddev.redis.php
- redis/scripts/setup-drupal-settings.sh
- redis/scripts/setup-redis-optimized-config.sh
- redis/redis.conf
- redis/advanced.conf
- redis/append.conf
- redis/general.conf
- redis/io.conf
- redis/memory.conf
- redis/network.conf
- redis/security.conf
- redis/snapshots.conf
- commands/host/redis-backend
- commands/redis/redis-cli
- commands/redis/redis-flush
global_files: []
removal_actions:
- |
#ddev-description:Remove redis settings if applicable
files=(
"${DDEV_APPROOT}/${DDEV_DOCROOT}/sites/default/settings.ddev.redis.php"
"${DDEV_APPROOT}/.ddev/docker-compose.redis_extra.yaml"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
if grep -q '#ddev-generated' "$file"; then
rm -f "$file"
else
echo "Unwilling to remove '$file' because it does not have #ddev-generated in it; you can manually delete it if it is safe to delete."
fi
fi
done
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#ddev-generated
## Description: Use a different key-value store for Redis
## Usage: redis-backend <image> [optimize]
## Example: ddev redis-backend redis-alpine optimize
REDIS_DOCKER_IMAGE=${1:-}
REDIS_CONFIG=${2:-}
NAME=$REDIS_DOCKER_IMAGE
function show_help() {
cat <<EOF
Usage: ddev redis-backend <image|alias> [optimize]
Choose from predefined aliases, or provide any Redis-compatible Docker image.
Note that not every Docker image can work right away, and you may need to override
the "command:" in the docker-compose.redis_extra.yaml file
Available aliases:
redis redis:7
redis-alpine redis:7-alpine
valkey valkey/valkey:8
valkey-alpine valkey/valkey:8-alpine
Custom backend:
You can specify any Docker image, e.g.:
ddev redis-backend redis:6
Optional:
optimize Apply additional Redis configuration with resource limits
optimized Same as optimize
Examples:
ddev redis-backend redis-alpine optimize
ddev redis-backend valkey
ddev redis-backend redis:7.2-alpine
EOF
exit 0
}
function optimize_config() {
[[ "$REDIS_CONFIG" != "optimized" && "$REDIS_CONFIG" != "optimize" ]] && return
ddev dotenv set .ddev/.env.redis --redis-optimized=true
}
function change_hostname() {
[[ "${REDIS_HOSTNAME:-}" == "" ]] && return
ddev dotenv set .ddev/.env.redis --redis-hostname="$REDIS_HOSTNAME"
}
function cleanup() {
rm -f "$DDEV_APPROOT/.ddev/.env.redis"
rm -rf "$DDEV_APPROOT/.ddev/redis/"
rm -f "$DDEV_APPROOT/.ddev/docker-compose.redis.yaml" "$DDEV_APPROOT/.ddev/docker-compose.redis_extra.yaml"
redis_volume="ddev-$(ddev status -j | docker run -i --rm ddev/ddev-utilities jq -r '.raw.name')_redis"
if docker volume ls -q | grep -qw "$redis_volume"; then
ddev stop
docker volume rm "$redis_volume"
fi
}
function check_docker_image() {
echo "Pulling ${REDIS_DOCKER_IMAGE}..."
if ! docker pull "$REDIS_DOCKER_IMAGE"; then
echo >&2 "❌ Unable to pull ${REDIS_DOCKER_IMAGE}"
exit 2
fi
}
function use_docker_image() {
[[ "$REDIS_DOCKER_IMAGE" != "redis:7" ]] && ddev dotenv set .ddev/.env.redis --redis-docker-image="$REDIS_DOCKER_IMAGE"
REPO=$(ddev add-on list --installed -j 2>/dev/null | docker run -i --rm ddev/ddev-utilities jq -r '.raw[] | select(.Name=="redis") | .Repository // empty' 2>/dev/null)
ddev add-on get "${REPO:-ddev/ddev-redis}"
}
case "$REDIS_DOCKER_IMAGE" in
redis)
NAME="Redis 7"
REDIS_DOCKER_IMAGE="redis:7"
;;
redis-alpine)
NAME="Redis 7 Alpine"
REDIS_DOCKER_IMAGE="redis:7-alpine"
;;
valkey)
NAME="Valkey 8"
REDIS_DOCKER_IMAGE="valkey/valkey:8"
REDIS_HOSTNAME="valkey"
;;
valkey-alpine)
NAME="Valkey 8 Alpine"
REDIS_DOCKER_IMAGE="valkey/valkey:8-alpine"
REDIS_HOSTNAME="valkey"
;;
""|--help|-h)
show_help
;;
*)
NAME="$REDIS_DOCKER_IMAGE"
# Allow unknown image, nothing to override
;;
esac
check_docker_image
cleanup
optimize_config
change_hostname
use_docker_image
echo
echo "✅ Redis backend: $REDIS_DOCKER_IMAGE"
if [[ "$REDIS_CONFIG" == "optimized" || "$REDIS_CONFIG" == "optimize" ]]; then
echo "⚙️ Redis config: optimized"
else
echo "⚙️ Redis config: default"
fi
echo
echo "📝 Commit the '.ddev' directory to version control"
echo
echo "🔄 Redis config available after 'ddev restart'"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
#ddev-generated
## Description: Run redis-cli inside the Redis container
## Usage: redis-cli [flags] [args]
## Example: "ddev redis-cli KEYS *" or "ddev redis-cli INFO" or "ddev redis-cli --version"
## Aliases: redis
if [ -f /etc/redis/conf/security.conf ]; then
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" -a redis --no-auth-warning $@
else
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" $@
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
#ddev-generated
## Description: Flush all cache inside the Redis container
## Usage: redis-flush
## Example: "ddev redis-flush"
if [ -f /etc/redis/conf/security.conf ]; then
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" -a redis --no-auth-warning FLUSHALL ASYNC
else
redis-cli -p 6379 -h "${REDIS_HOSTNAME:-redis}" FLUSHALL ASYNC
fi
+77 -40
View File
@@ -1,9 +1,8 @@
name: ep-reisen
type: typo3 type: typo3
docroot: public docroot: public
php_version: "7.4" php_version: "7.4"
webserver_type: nginx-fpm webserver_type: nginx-fpm
router_http_port: "80"
router_https_port: "443"
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: additional_hostnames:
- ep-events - ep-events
@@ -16,36 +15,58 @@ additional_hostnames:
additional_fqdns: [] additional_fqdns: []
database: database:
type: mariadb type: mariadb
version: "10.2" version: "11.4"
hooks:
post-start:
- exec: .ddev/claude-code/install.sh
- exec: .ddev/codex-cli/install.sh
webimage_extra_packages: [ripgrep, bubblewrap, socat]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: web_environment:
- TYPO3_CONTEXT=Development - TYPO3_CONTEXT=Development
nodejs_version: "18" nodejs_version: "24"
corepack_enable: false
omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides # name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site # http://projectname.ddev.site and https://projectname.ddev.site
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # drupal6/7/8, backdrop, typo3, wordpress, php # type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.1" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" # php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # nginx/php docker image. # webimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.4" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0 # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# PostgreSQL versions can be 9-16. # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -55,14 +76,24 @@ nodejs_version: "18"
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, # "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit. # as leaving Xdebug enabled all the time is a big performance hit.
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" # xhgui_http_port: "8143"
# Note that for most people the commands # xhgui_https_port: "8142"
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, # The XHGui ports can be changed from the default 8143 and 8142
# as leaving Xhprof enabled all the time is a big performance hit. # Very rarely used
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn # host_xhgui_port: "8142"
# Can be used to change the host binding port of the XHGui
# application. Rarely used; only when port conflict and
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Default is "xhgui"
# webserver_type: nginx-fpm, apache-fpm, generic
# timezone: Europe/Berlin # timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
# using the $TZ environment variable or the /etc/localtime symlink.
# This is the timezone used in the containers and by PHP; # This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone, # it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
@@ -74,22 +105,27 @@ nodejs_version: "18"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug refresh". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "18" # nodejs_root: <relative_path>
# change from the default system Node.js version to another supported version, like 16, 18, 20. # Relative path to the directory containing the Node.js version file from the
# Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any # project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# Node.js version, including v6, etc.
# You only need to configure this if you are not using nvm and you want to use a major # nodejs_version: "24"
# version that is not the default. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
# additional_hostnames: # additional_hostnames:
# - somename # - somename
@@ -119,7 +155,7 @@ nodejs_version: "18"
# ddev_version_constraint: "" # ddev_version_constraint: ""
# Example: # Example:
# ddev_version_constraint: ">= 1.22.4" # ddev_version_constraint: ">= 1.24.8"
# This will enforce that the running ddev version is within this constraint. # This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for # See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats # supported constraint formats
@@ -146,10 +182,8 @@ nodejs_version: "18"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook # Decide whether 'ddev start' should be interrupted by a failing hook
@@ -178,10 +212,10 @@ nodejs_version: "18"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -193,16 +227,19 @@ nodejs_version: "18"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php # Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
# In this case the user must provide all such settings. # In this case the user must provide all such settings.
# You can inject environment variables into the web container with: # You can inject environment variables into the web container with:
@@ -260,7 +297,7 @@ nodejs_version: "18"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -270,13 +307,13 @@ nodejs_version: "18"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db", # DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer" # "pre-composer", "post-composer"
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more # See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define # information on the commands that can be extended and the tasks you can define
# for them. Example: # for them. Example:
#hooks: #hooks:
+27
View File
@@ -0,0 +1,27 @@
#ddev-generated
services:
redis:
container_name: ddev-${DDEV_SITENAME}-redis
image: ${REDIS_DOCKER_IMAGE:-redis:7}
hostname: ${REDIS_HOSTNAME:-redis}
# These labels ensure this service is discoverable by ddev.
labels:
com.ddev.site-name: ${DDEV_SITENAME}
com.ddev.approot: ${DDEV_APPROOT}
restart: "no"
expose:
- 6379
volumes:
- ".:/mnt/ddev_config"
- "ddev-global-cache:/mnt/ddev-global-cache"
- "./redis:/etc/redis/conf"
- "redis:/data"
command: /etc/redis/conf/redis.conf
x-ddev:
describe-url-port: |
Backend: ${REDIS_DOCKER_IMAGE:-redis:7}
describe-info: |
Pass: <none>
volumes:
redis:
+13
View File
@@ -0,0 +1,13 @@
# Redis configuration.
# #ddev-generated
# Example configuration files for reference:
# http://download.redis.io/redis-stable/redis.conf
# http://download.redis.io/redis-stable/sentinel.conf
maxmemory 128mb
maxmemory-policy allkeys-lru
# to disable Redis persistence, remove ddev-generated from this file,
# and uncomment the two lines below:
#appendonly no
#save ""
+1 -1
View File
@@ -15,9 +15,9 @@ public/typo3conf/ext/*/
public/typo3conf/ext/ep_events/Resources/Public/ public/typo3conf/ext/ep_events/Resources/Public/
public/typo3conf/ext/ep_theme/Resources/Public/ public/typo3conf/ext/ep_theme/Resources/Public/
public/typo3conf/LocalConfiguration.php public/typo3conf/LocalConfiguration.php
public/typo3conf/AdditionalConfiguration.php
public/typo3conf/PackageStates.php public/typo3conf/PackageStates.php
public/typo3conf/l10n public/typo3conf/l10n
public/typo3temp public/typo3temp
public/uploads public/uploads
var var
http-client.private.env.json
-3284
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
16 24
+37 -7
View File
@@ -1,12 +1,42 @@
# Backchannel endpoints MyE&P reads hotel and product data from. Same environment files and key
# as content-feed.http: pick "local" and put cmsApiKey in http-client.private.env.json.
### GET product data for myep ### GET product data for myep
GET https://ep-reisen.ddev.site/api/product GET {{host}}/api/product?product=3VIIN&hotel=VLTS
?product=SSTMR X-Api-Key: {{cmsApiKey}}
&hotel=SSTGR
&key=abcabc123 > {%
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### GET hotel data for myep ### GET hotel data for myep
GET https://ep-reisen.ddev.site/api/hotel GET {{host}}/api/hotel?hotel=DPWGRP
?hotel=SSTGR X-Api-Key: {{cmsApiKey}}
&key=abcabc123
> {%
client.test("200 with key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Transition form: key as query parameter, as MyE&P sends it today (still accepted)
GET {{host}}/api/hotel?hotel=DPWGRP&key={{cmsApiKey}}
> {%
client.test("200 with query key", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
});
%}
### Any other value is refused now - this used to pass
GET {{host}}/api/hotel?hotel=DPWGRP&key=AbcAbc123
> {%
client.test("403 with the old dummy key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### ###
+4 -1
View File
@@ -15,6 +15,7 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"b13/container": "^1.3", "b13/container": "^1.3",
"blueways/bw-captcha": "^3.1", "blueways/bw-captcha": "^3.1",
@@ -24,11 +25,13 @@
"georgringer/news": "^8.5", "georgringer/news": "^8.5",
"gridelementsteam/gridelements": "^10.0", "gridelementsteam/gridelements": "^10.0",
"helhum/typo3-console": "^6.4", "helhum/typo3-console": "^6.4",
"jweiland/replacer": "^2.1",
"kigkonsult/icalcreator": "^2.29", "kigkonsult/icalcreator": "^2.29",
"league/csv": "^9.2", "league/csv": "^9.2",
"league/oauth2-client": "^2.8", "league/oauth2-client": "^2.8",
"league/period": "^4.9", "league/period": "^4.9",
"lochmueller/staticfilecache": "^12.5", "lochmueller/staticfilecache": "^12.5",
"phpseclib/phpseclib": "^3.0",
"sjbr/sr-freecap": "2.6.0", "sjbr/sr-freecap": "2.6.0",
"ssch/typo3-encore": "^3.0", "ssch/typo3-encore": "^3.0",
"symfony/http-client": "^4.3", "symfony/http-client": "^4.3",
@@ -95,7 +98,7 @@
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "7.4.13" "php": "7.4.33"
}, },
"allow-plugins": { "allow-plugins": {
"typo3/cms-composer-installers": true, "typo3/cms-composer-installers": true,
Generated
+338 -43
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f0ff2587673ce0da1d0d53cc382a0922", "content-hash": "38fb94ebf504e8413d37c94f929e4b47",
"packages": [ "packages": [
{ {
"name": "b13/container", "name": "b13/container",
@@ -1583,6 +1583,72 @@
], ],
"time": "2023-01-08T21:22:53+00:00" "time": "2023-01-08T21:22:53+00:00"
}, },
{
"name": "jweiland/replacer",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/jweiland-net/replacer.git",
"reference": "feec487ea9c3573d1e8d80245dd9763352492357"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jweiland-net/replacer/zipball/feec487ea9c3573d1e8d80245dd9763352492357",
"reference": "feec487ea9c3573d1e8d80245dd9763352492357",
"shasum": ""
},
"require": {
"typo3/cms-core": "^9.5 || ^10.4 || ^11.0"
},
"replace": {
"typo3-ter/replacer": "self.version"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"nimut/testing-framework": "^6.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-latest"
},
"type": "typo3-cms-extension",
"extra": {
"typo3/cms": {
"app-dir": ".build",
"web-dir": ".build/public",
"extension-key": "replacer"
}
},
"autoload": {
"psr-4": {
"JWeiland\\Replacer\\": "Classes"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Pascal Rinker",
"email": "[email protected]",
"role": "Developer"
}
],
"description": "Replaces string patterns from the page. You can use it to replace URLs for Content Delivery Network (CDN).",
"homepage": "http://www.jweiland.net",
"keywords": [
"TYPO3 CMS",
"jw",
"replacer",
"typo3"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/jweiland-net/replacer/issues",
"source": "https://github.com/jweiland-net/replacer"
},
"time": "2022-06-02T09:29:20+00:00"
},
{ {
"name": "kigkonsult/icalcreator", "name": "kigkonsult/icalcreator",
"version": "v2.39.2", "version": "v2.39.2",
@@ -1751,21 +1817,22 @@
}, },
{ {
"name": "league/oauth2-client", "name": "league/oauth2-client",
"version": "2.9.0", "version": "2.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git", "url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" "reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", "guzzlehttp/guzzle": "^6.5.8 || ^7.8.2 || ^8.0",
"guzzlehttp/psr7": "^1.9.1 || ^2.6.3 || ^3.0",
"php": "^7.1 || >=8.0.0 <8.6.0" "php": "^7.1 || >=8.0.0 <8.6.0"
}, },
"require-dev": { "require-dev": {
@@ -1810,9 +1877,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues", "issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.1"
}, },
"time": "2025-11-25T22:17:17+00:00" "time": "2026-09-16T13:14:31+00:00"
}, },
{ {
"name": "league/period", "name": "league/period",
@@ -2055,24 +2122,24 @@
}, },
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
"version": "2.10.1", "version": "2.11.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Masterminds/html5-php.git", "url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29" "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29", "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"php": ">=5.3.0" "php": ">=7.4"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -2116,9 +2183,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/Masterminds/html5-php/issues", "issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1" "source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
}, },
"time": "2026-06-23T18:43:15+00:00" "time": "2026-08-18T06:18:41+00:00"
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
@@ -2171,6 +2238,123 @@
}, },
"time": "2025-12-06T11:45:25+00:00" "time": "2025-12-06T11:45:25+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
"shasum": ""
},
"require": {
"php": "^7|^8"
},
"require-dev": {
"phpunit/phpunit": "^6|^7|^8|^9",
"vimeo/psalm": "^1|^2|^3|^4"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "[email protected]",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "[email protected]",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:12:37+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "[email protected]",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "[email protected]",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.0",
@@ -2347,17 +2531,127 @@
"time": "2025-11-21T15:09:14+00:00" "time": "2025-11-21T15:09:14+00:00"
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpseclib/phpseclib",
"version": "2.3.3", "version": "3.0.57",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpseclib/phpseclib.git",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "[email protected]",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "[email protected]",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "[email protected]",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-08-26T12:13:21+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2389,9 +2683,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
}, },
"time": "2026-07-08T07:01:06+00:00" "time": "2026-08-31T16:05:28+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -4647,16 +4941,16 @@
}, },
{ {
"name": "symfony/polyfill-intl-grapheme", "name": "symfony/polyfill-intl-grapheme",
"version": "v1.38.1", "version": "v1.41.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "e9247d281d694a5120554d9afaf54e070e88a603" "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"reference": "e9247d281d694a5120554d9afaf54e070e88a603", "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4705,7 +4999,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
}, },
"funding": [ "funding": [
{ {
@@ -4725,7 +5019,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T05:58:03+00:00" "time": "2026-07-28T08:25:59+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-icu", "name": "symfony/polyfill-intl-icu",
@@ -4817,16 +5111,16 @@
}, },
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.38.1", "version": "v1.42.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git", "url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "dc21118016c039a66235cf93d96b435ffb282412" "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9",
"reference": "dc21118016c039a66235cf93d96b435ffb282412", "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4880,7 +5174,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0"
}, },
"funding": [ "funding": [
{ {
@@ -4900,20 +5194,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-25T15:22:23+00:00" "time": "2026-08-24T10:51:20+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-normalizer", "name": "symfony/polyfill-intl-normalizer",
"version": "v1.38.0", "version": "v1.42.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502",
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4965,7 +5259,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0"
}, },
"funding": [ "funding": [
{ {
@@ -4985,7 +5279,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-25T13:48:31+00:00" "time": "2026-08-07T06:33:24+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
@@ -9354,11 +9648,12 @@
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-redis": "*",
"ext-simplexml": "*" "ext-simplexml": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"platform-overrides": { "platform-overrides": {
"php": "7.4.13" "php": "7.4.33"
}, },
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }
+141
View File
@@ -0,0 +1,141 @@
# Chatbot content feed (see .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md)
#
# Pick the "local" environment in PhpStorm and run the whole file top to bottom: the manifest
# request stores uids and a content URL that the later requests use.
#
# Needs, locally:
# - http-client.private.env.json with {"local": {"cmsApiKey": "<your key>"}} (gitignored)
# - the same key in TYPO3, e.g.
# ddev exec vendor/bin/typo3cms configuration:set EXTENSIONS/ep_products/cmsApiKey <your key>
# plus contentFeedSites etc. - or open and save the ep_products extension configuration once
# in the backend, which writes all the defaults.
#
# The kill switch (contentFeedEnabled = 0) cannot be toggled from here; with it off, every keyed
# request below answers 503.
### Manifest without a key is refused
GET {{host}}/api/content/manifest
> {%
client.test("403 without key", function () {
client.assert(response.status === 403, "expected 403, got " + response.status);
});
%}
### Manifest
GET {{host}}/api/content/manifest
X-Api-Key: {{cmsApiKey}}
> {%
client.test("manifest is complete and well-formed", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length > 0, "manifest is empty");
client.assert(response.body.sites.length > 0, "no sites");
client.assert(
response.body.items.every(function (item) { return item.type && item.uid && item.modified; }),
"an item lacks type, uid or modified"
);
});
var uidsOf = function (type, limit) {
return response.body.items
.filter(function (item) { return item.type === type; })
.slice(0, limit)
.map(function (item) { return item.uid; })
.join(",");
};
client.global.set("productUids", uidsOf("product", 5));
client.global.set("hotelUids", uidsOf("hotel", 5));
client.global.set("regionUids", uidsOf("region", 5));
client.global.set("newsUids", uidsOf("news", 3));
var page = response.body.items.filter(function (item) { return item.type === "page" && item.contentUrl; })[0];
client.global.set("contentUrl", page.contentUrl);
var tooMany = [];
for (var i = 1; i <= 101; i++) { tooMany.push(i); }
client.global.set("tooManyUids", tooMany.join(","));
%}
### Products (with FAQs)
GET {{host}}/api/content/records?type=product&uids={{productUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("every requested product comes back, links resolved", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.length === client.global.get("productUids").split(",").length, "products missing");
client.assert(JSON.stringify(response.body).indexOf("t3://") === -1, "unresolved t3:// link");
});
%}
### Hotels (codes include matchcode aliases)
GET {{host}}/api/content/records?type=hotel&uids={{hotelUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("hotels carry their codes", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.items.every(function (hotel) { return Array.isArray(hotel.codes); }), "hotel without codes");
});
%}
### Regions
GET {{host}}/api/content/records?type=region&uids={{regionUids}}
X-Api-Key: {{cmsApiKey}}
### Blog articles
GET {{host}}/api/content/records?type=news&uids={{newsUids}}
X-Api-Key: {{cmsApiKey}}
### Unknown type is a 400
GET {{host}}/api/content/records?type=page&uids=1
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 for unknown type", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### More than 100 uids is a 400
GET {{host}}/api/content/records?type=product&uids={{tooManyUids}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("400 above 100 uids", function () {
client.assert(response.status === 400, "expected 400, got " + response.status);
});
%}
### Content-only render of the first page in the manifest
GET {{contentUrl}}
X-Api-Key: {{cmsApiKey}}
> {%
client.test("bare article, never cached by proxies", function () {
client.assert(response.status === 200, "expected 200, got " + response.status);
client.assert(response.body.indexOf("<article>") === 0, "not a bare <article> fragment");
client.assert(response.headers.valueOf("Cache-Control") === "private, no-store", "unexpected Cache-Control");
});
%}
### Content-only render without a key is a 404 (the type must not be discoverable)
GET {{contentUrl}}
> {%
client.test("404 without key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
### Content-only render with a wrong key is a 404
GET {{contentUrl}}
X-Api-Key: definitely-wrong
> {%
client.test("404 with wrong key", function () {
client.assert(response.status === 404, "expected 404, got " + response.status);
});
%}
+24 -41
View File
@@ -25,6 +25,7 @@ add('shared_dirs', [
$rsyncOptions = [ $rsyncOptions = [
'exclude' => [ 'exclude' => [
'.ddev', '.ddev',
'.claude',
'.DS_Store', '.DS_Store',
'.git', '.git',
'.github', '.github',
@@ -37,7 +38,6 @@ $rsyncOptions = [
'var', 'var',
'.editorconfig', '.editorconfig',
'.gitignore', '.gitignore',
'.htaccess_prod',
'deploy.php', 'deploy.php',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -51,73 +51,56 @@ $rsyncOptions = [
'filter' => [], 'filter' => [],
'filter-file' => false, 'filter-file' => false,
'filter-perdir' => false, 'filter-perdir' => false,
'flags' => 'rz', // -t preserves mtimes: without it every file lands with a fresh timestamp, so unchanged
// files get a new Last-Modified on every deploy and every conditional request revalidates
// into a full response instead of a 304.
'flags' => 'rzt',
'options' => ['delete', 'links', 'chmod=D755,F664'], 'options' => ['delete', 'links', 'chmod=D755,F664'],
'timeout' => 300, 'timeout' => 300,
]; ];
host('production') host('prod')
->setHostname('185.237.67.190') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p546128') ->setRemoteUser('eptypo3')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p546128/html/{{application}}') ->setDeployPath('/usr/home/eptypo3/public_html/{{application}}')
->set('buspronet_path', '/home/www/p546128/html/kuschick/buspronet/buspronet') ->set('bin/php', '/usr/bin/php74')
->set('bin/php', '/usr/local/bin/php') ->set('http_user', 'eptypo3')
->set('http_user', 'p546128')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('bin/composer', '/usr/local/bin/composer')
->set('composer_options', '--prefer-dist --no-progress --no-interaction --optimize-autoloader')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://www.ep-reisen.de') // 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.
$sharedFilesStaging = get('shared_files'); ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{deploy_path}}/shared/{{typo3_webroot}}/typo3temp/ --web-url=https://www.ep-reisen.de/typo3temp')
$sharedFilesStaging[] = '{{typo3_webroot}}/.htpasswd';
host('staging')
->setHostname('185.237.67.190')
->setRemoteUser('p546128')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p546128/html/{{application}}-staging')
->set('buspronet_path', '/home/www/p546128/html/kuschick/buspronet/buspronet')
->set('bin/php', '/usr/local/bin/php')
->set('http_user', 'p546128')
->set('writable_mode', 'chmod')
->set('bin/composer', '/usr/local/bin/composer')
->set('composer_options', '--prefer-dist --no-progress --no-interaction --optimize-autoloader')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://staging.ep-reisen.de --web-basic-auth=ep:reisen')
->set('shared_files', $sharedFilesStaging)
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
// Build locally first, so a failing build aborts before the server is touched at all.
'deploy:assets:build',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets:build',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
// The schema has to exist before the new code starts serving traffic.
'typo3:database:migrate', 'typo3:database:migrate',
'typo3:language:update', 'typo3:language:update',
'typo3:cache:flush', // deploy:publish == deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success
'cachetool:clear:opcache',
'deploy:publish', 'deploy:publish',
'deploy:buspronet:symlink',
]); ]);
task('deploy:buspronet:symlink', function () { // Caches belong to the release that goes live, so they are cleared after the symlink flip. Hooking
run('ln -s {{buspronet_path}} {{release_or_current_path}}/public/buchung'); // into deploy:symlink rather than appending to the list above keeps them inside deploy:publish, ahead
}); // of deploy:unlock and deploy:success - a failing cache task must not follow a declared success.
after('deploy:symlink', 'cachetool:clear:opcache');
after('deploy:symlink', 'typo3:cache:flush');
task ('deploy:assets:build', function () { task ('deploy:assets:build', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm rebuild node-sass');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
}); });
+8
View File
@@ -0,0 +1,8 @@
{
"local": {
"host": "https://ep-reisen.ddev.site"
},
"prod": {
"host": "https://www.ep-reisen.de"
}
}
+1518 -1738
View File
File diff suppressed because it is too large. Load diff
+9 -23
View File
@@ -1,62 +1,48 @@
{ {
"name": "eptheme", "name": "eptheme",
"description": "EP Reisen TYPO3 website package",
"version": "3.0.0",
"author": "Björn Fromme, [email protected]",
"license": "proprietary",
"scripts": { "scripts": {
"dev": "./node_modules/.bin/encore dev --watch", "dev": "./node_modules/.bin/encore dev --watch",
"build": "NODE_ENV=production ./node_modules/.bin/encore production" "build": "NODE_ENV=production ./node_modules/.bin/encore production"
}, },
"engines": { "engines": {
"node": ">=16" "node": ">=20.19.0"
}, },
"browserslist": [
"defaults"
],
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.1", "@hotwired/stimulus": "^3.2.1",
"@hotwired/stimulus-webpack-helpers": "^1.0.1", "@hotwired/stimulus-webpack-helpers": "^1.0.1",
"@iframe-resizer/parent": "^5.3.2", "@iframe-resizer/parent": "^5.3.2",
"@splidejs/splide": "^2.4.21", "@splidejs/splide": "^2.4.21",
"axios": "^1.6.2",
"cooltipz-css": "^1.6.8", "cooltipz-css": "^1.6.8",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"es6-slide-up-down": "^1.0.0",
"flatpickr": "^4.6.9", "flatpickr": "^4.6.9",
"glightbox": "^3.0.9", "glightbox": "^3.0.9",
"htmx.org": "^1.9.12", "htmx.org": "^1.9.12",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"lazysizes": "^5.3.2",
"leaflet": "^1.7.1", "leaflet": "^1.7.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"picturefill": "^3.0.3",
"slick-carousel": "^1.8.1", "slick-carousel": "^1.8.1",
"sticky-js": "^1.3.0", "sticky-js": "^1.3.0",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-router": "^3.5.2", "vue-session": "^1.0.0"
"vue-session": "^1.0.0",
"vuex": "^3.6.2",
"whatwg-fetch": "^3.6.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-object-rest-spread": "^7.16.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^2.1.0", "@symfony/webpack-encore": "^4.7.0",
"@tailwindcss/aspect-ratio": "^0.4.0", "@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"core-js": "^3.22.5",
"document-register-element": "^1.14.10",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"postcss": "^8.4.16", "postcss": "^8.4.16",
"postcss-loader": "^6.2.1", "postcss-loader": "^8.2.1",
"sass": "^1.53.0", "sass": "^1.53.0",
"sass-loader": "^12.6.0", "sass-loader": "^14.2.1",
"sass-mq": "^6.0.0", "sass-mq": "^7.0.1",
"smoothscroll-polyfill": "^0.4.4",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.1.8",
"tailwindcss-alt": "^3.0.0", "tailwindcss-alt": "^3.0.0",
"vue-custom-element": "^3.3.0",
"vue-loader": "^15.11.1", "vue-loader": "^15.11.1",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14"
} }
+113 -9
View File
@@ -75,6 +75,58 @@ AddType image/webp .webp
</IfModule> </IfModule>
</IfModule> </IfModule>
# Brotli, where the host has the module. It beats gzip by roughly 15-20% on the theme's
# minified css/js at no measurable cost, and falls back to the mod_deflate block above for
# clients that do not announce `br` - which is also what happens wholesale if the module is
# missing, because the whole block is then skipped.
#
# The type list is deliberately the same one mod_deflate gets: `no-gzip` below switches gzip
# off for every br-capable client, so a type listed for DEFLATE only would reach those
# clients uncompressed.
<IfModule mod_brotli.c>
# Both filters would otherwise run for a client announcing `br, gzip` and gzip the brotli
# output on top of itself. mod_deflate honours `no-gzip`, so brotli wins where it is
# offered and gzip keeps everyone else.
<IfModule mod_setenvif.c>
SetEnvIfNoCase Accept-Encoding "(^|[\s,])br($|[\s,;])" no-gzip
</IfModule>
<IfModule mod_filter.c>
AddOutputFilterByType BROTLI_COMPRESS application/atom+xml \
application/javascript \
application/json \
application/ld+json \
application/manifest+json \
application/rdf+xml \
application/rss+xml \
application/schema+json \
application/vnd.geo+json \
application/vnd.ms-fontobject \
application/x-font-ttf \
application/x-javascript \
application/x-web-app-manifest+json \
application/xhtml+xml \
application/xml \
font/eot \
font/opentype \
image/bmp \
image/svg+xml \
image/vnd.microsoft.icon \
image/x-icon \
text/cache-manifest \
text/css \
text/html \
text/javascript \
text/plain \
text/vcard \
text/vnd.rim.location.xloc \
text/vtt \
text/x-component \
text/x-cross-domain-policy \
text/xml
</IfModule>
</IfModule>
### End: Compression ### ### End: Compression ###
@@ -139,6 +191,16 @@ AddType image/webp .webp
</IfModule> </IfModule>
# Webpack writes an 8-hex-character content hash into the filename of everything it emits, so
# these URLs can never change meaning - a new build is a new URL. `immutable` stops browsers
# revalidating them on reload, and overrides the per-type rules above, which would otherwise
# expire the hashed fonts after a month.
<IfModule mod_headers.c>
<FilesMatch "\.[0-9a-f]{8}\.(js|css|woff2?|jpe?g|png|gif|svg|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
### End: Browser caching of resource files ### ### End: Browser caching of resource files ###
@@ -223,9 +285,6 @@ AddDefaultCharset utf-8
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$"> <FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Origin "*"
</FilesMatch> </FilesMatch>
<FilesMatch "\.(png|gif|jpe?g)$">
Header append Vary Accept
</FilesMatch>
</IfModule> </IfModule>
### End: Cross Origin ### ### End: Cross Origin ###
@@ -241,6 +300,57 @@ AddDefaultCharset utf-8
# Enable URL rewriting # Enable URL rewriting
RewriteEngine On RewriteEngine On
# Legacy and campaign domains, consolidated onto their current host. These have to stay
# ahead of the Force SSL block and of TYPO3's catch-all at the end of this file, or a
# request for one of them reaches index.php before it is ever redirected.
#
# Maintained here rather than on the host: the deploy rsyncs this file into every release
# (deploy.php), so anything edited live is gone after the next one.
RewriteRule ^buchung/(.*)$ https://bpn.ep-reisen.de/$1 [R=302,L,QSA]
RewriteCond %{HTTP_HOST} ^cllt\-touristik\.at$ [NC]
RewriteRule ^(.*) https://www.cllt-touristik.at/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^alpinevacation\.ch$ [NC]
RewriteRule ^(.*) https://www.alpinevacation.ch/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?semesterendeskireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^semesterende\-skireisen\.de$ [NC]
RewriteRule ^(.*) https://www.semesterende-skireisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^snowzone\.net$ [NC]
RewriteRule ^(.*) https://www.snowzone.net/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-boarderweek\.de$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ski\-boarderweek\.(com|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} skiboarderweek\.(de|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-wochenenden\.de$ [NC]
RewriteRule ^(.*) https://www.ski-wochenenden.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-events\.de$ [NC]
RewriteRule ^(.*) https://www.ep-events.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-reisen\.de$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
# The dropped path is deliberate: snow-break sends every deep link to the snowbreak
# landing page, unlike unichamp below, which keeps it.
RewriteCond %{HTTP_HOST} ^(www\.)?snow\-break\.(de|com) [NC]
RewriteRule (.*) https://snowbreak.ep-reisen.de/ [R=302,L]
# Matches the bare domain as well, so it lands on snowbreak in one hop - a separate
# unichamp.de -> www.unichamp.de rule used to run first and cost an extra 301.
RewriteCond %{HTTP_HOST} ^(www\.)?unichamp\.de$ [NC]
RewriteRule ^(.*) https://snowbreak.ep-reisen.de/$1 [L,R=302]
# Force SSL # Force SSL
RewriteCond %{HTTPS} off RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} =http RewriteCond %{HTTP:X-Forwarded-Proto} =http
@@ -282,12 +392,6 @@ AddDefaultCharset utf-8
# NOTE: Add your additional local storages here # NOTE: Add your additional local storages here
RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L] RewriteRule ^(?:typo3/|fileadmin/|typo3conf/|typo3temp/|uploads/|favicon\.ico) - [L]
RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME} (.*)\.(png|gif|jpe?g)$
RewriteCond %{REQUEST_FILENAME}\.webp -f
RewriteRule ^ %{REQUEST_FILENAME}\.webp [L,T=image/webp]
### Begin: StaticFileCache (preparation) #### ### Begin: StaticFileCache (preparation) ####
# Document root configuration # Document root configuration
@@ -0,0 +1,127 @@
<?php
$redisOptions = [
'hostname' => '/run/redis_eptypo3/redis.sock',
'port' => 0,
];
if (getenv('IS_DDEV_PROJECT') == 'true') {
$redisOptions = [
'hostname' => 'redis',
'port' => 6379,
];
$GLOBALS['TYPO3_CONF_VARS'] = array_replace_recursive(
$GLOBALS['TYPO3_CONF_VARS'],
[
'DB' => [
'Connections' => [
'Default' => [
'dbname' => 'db',
'driver' => 'mysqli',
'host' => 'db',
'password' => 'db',
'port' => '3306',
'user' => 'db',
],
],
],
// This GFX configuration allows processing by installed ImageMagick 6
'GFX' => [
'processor' => 'ImageMagick',
'processor_path' => '/usr/bin/',
'processor_path_lzw' => '/usr/bin/',
],
// This mail configuration sends all emails to mailpit
'MAIL' => [
'transport' => 'smtp',
'transport_smtp_encrypt' => false,
'transport_smtp_server' => 'localhost:1025',
],
'SYS' => [
'trustedHostsPattern' => '.*.*',
'devIPmask' => '*',
'displayErrors' => 1,
],
]
);
}
// Fail fast instead of blocking indefinitely when redis is unreachable.
$redisOptions['connectionTimeout'] = 2;
// Note: the cache identifiers must stay unprefixed ('pages', not 'cache_pages'). These
// assignments replace the definitions from LocalConfiguration.php wholesale, so 'groups'
// and the compression/lifetime options have to be repeated here.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 0,
'compression' => true,
],
'groups' => ['pages'],
];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['hash'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 1,
],
'groups' => ['pages'],
];
// Own database: RedisBackend::flush() issues FLUSHDB and the keys carry no per-cache
// prefix, so two caches sharing a database flush each other.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class,
'options' => $redisOptions + [
'database' => 2,
'compression' => true,
'defaultLifetime' => 2592000,
],
'groups' => ['pages'],
];
// Assets are served through the bunny.net CDN, which gzip/brotli-compresses at the edge.
// Pre-gzipped .gzip twins are served with Content-Encoding: gzip and cannot be processed
// by the CDN, so stop generating them. mod_deflate still compresses direct origin hits.
$GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 0;
// 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;
/*
$GLOBALS['TYPO3_CONF_VARS']['BE']['cookieSameSite'] = 'lax';
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['oauth2_client'] = [
'providers' => [
'myep' => [
'label' => 'MyEP',
'iconIdentifier' => 'ep-logo',
'scopes' => [
\Waldhacker\Oauth2Client\Service\Oauth2ProviderManager::SCOPE_BACKEND,
],
'options' => [
'clientId' => '8df8f314ce17e8b716f86f9c24564746',
'clientSecret' => '7ff2532abc06f2b48438fc2e6e0278c8fdfb0e2de839889f4d5d17fd4a37cfffcca3bb19166331cc6ced462550611b33f08e9a3a993270d8c786a884c7fe935b',
'urlAuthorize' => 'https://myep-next-booking.ddev.site/authorize',
'urlAccessToken' => 'https://myep-next-booking.ddev.site/token',
'urlResourceOwnerDetails' => 'https://myep-next-booking.ddev.site/api/userinfo',
'scopes' => ['email', 'id', 'roles', 'profile'],
'scopeSeparator' => ' ',
],
],
],
];
*/
@@ -59,6 +59,7 @@ call_user_func(function() {
'label' => 'Reiseart', 'label' => 'Reiseart',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -74,6 +75,7 @@ call_user_func(function() {
'label' => 'Angebot/Beispielangebot', 'label' => 'Angebot/Beispielangebot',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['Keine Zuordnung', 0], ['Keine Zuordnung', 0],
@@ -39,6 +39,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -53,6 +54,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -36,6 +36,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -50,6 +51,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -37,6 +37,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -51,6 +52,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -38,6 +38,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -52,6 +53,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -50,6 +50,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -64,6 +65,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -449,6 +451,7 @@ return [
'label' => 'Ansprechpartner', 'label' => 'Ansprechpartner',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_contact', 'foreign_table' => 'tx_epevents_domain_model_contact',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -461,6 +464,7 @@ return [
'label' => 'Unterkunft', 'label' => 'Unterkunft',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_hotel', 'foreign_table' => 'tx_epevents_domain_model_hotel',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -473,6 +477,7 @@ return [
'label' => 'Location', 'label' => 'Location',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'tx_epevents_domain_model_location', 'foreign_table' => 'tx_epevents_domain_model_location',
'foreign_table_where' => 'AND 1=1 order by name', 'foreign_table_where' => 'AND 1=1 order by name',
@@ -35,6 +35,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -49,6 +50,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
@@ -38,6 +38,7 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'foreign_table' => 'sys_language', 'foreign_table' => 'sys_language',
'foreign_table_where' => 'ORDER BY sys_language.title', 'foreign_table_where' => 'ORDER BY sys_language.title',
@@ -52,12 +53,13 @@ return [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent', 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'default' => 0,
'renderType' => 'selectSingle', 'renderType' => 'selectSingle',
'items' => [ 'items' => [
['', 0], ['', 0],
], ],
'foreign_table' => 'tx_epevents_domain_model_offer', 'foreign_table' => 'tx_epevents_domain_model_traveltype',
'foreign_table_where' => 'AND tx_epevents_domain_model_offer.pid=###CURRENT_PID### AND tx_epevents_domain_model_offer.sys_language_uid IN (-1,0)', 'foreign_table_where' => 'AND tx_epevents_domain_model_traveltype.pid=###CURRENT_PID### AND tx_epevents_domain_model_traveltype.sys_language_uid IN (-1,0)',
], ],
], ],
'l10n_diffsource' => [ 'l10n_diffsource' => [
@@ -4,9 +4,6 @@ import Vue from 'vue'
import $ from 'jquery' import $ from 'jquery'
import VueSession from 'vue-session' import VueSession from 'vue-session'
import 'lazysizes';
import 'lazysizes/plugins/attrchange/ls.attrchange';
const slick = require('slick-carousel'); const slick = require('slick-carousel');
const Sticky = require('sticky-js'); const Sticky = require('sticky-js');
const $window = $(window); const $window = $(window);
@@ -38,9 +38,9 @@
<div class="card card--no-form offerlist-item__card"> <div class="card card--no-form offerlist-item__card">
<div class="card__inner"> <div class="card__inner">
<picture> <picture>
<source media="(max-width: 512px)" :data-srcset="offer.teaserImageMobile + ' 500w'"/> <source media="(max-width: 512px)" :srcset="offer.teaserImageMobile"/>
<img class="card__image scale lazyload" data-sizes="auto" <img class="card__image scale"
:data-src="offer.teaserImage" alt=""> :src="offer.teaserImage" alt="" loading="lazy">
</picture> </picture>
<div class="card__caption"> <div class="card__caption">
<span class="card__subtitle" v-html="offer.name"></span> <span class="card__subtitle" v-html="offer.name"></span>
@@ -55,7 +55,6 @@
</template> </template>
<script> <script>
import axios from 'axios';
import $ from 'jquery'; import $ from 'jquery';
export default { export default {
@@ -82,13 +81,21 @@
load() { load() {
this.loading = true; this.loading = true;
sessionStorage.setItem('filterSettings', JSON.stringify(this.filterSettings)); sessionStorage.setItem('filterSettings', JSON.stringify(this.filterSettings));
axios({ fetch(this.endpointUri, {
url: this.endpointUri, method: 'POST',
method: 'post', credentials: 'same-origin',
data: $.param({'tx_epevents_ajax[filterSettings]': this.filterSettings}) headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: $.param({'tx_epevents_ajax[filterSettings]': this.filterSettings})
}).then(response => { }).then(response => {
this.offers = response.data.offers; // fetch only rejects on network errors, so surface HTTP errors here
this.filterOptions = response.data.filterOptions; // to keep the previous behaviour of falling through to .catch()
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}).then(data => {
this.offers = data.offers;
this.filterOptions = data.filterOptions;
this.loading = false; this.loading = false;
}).catch(() => { }).catch(() => {
this.loading = false; this.loading = false;
@@ -1,7 +1,10 @@
@use "breakpoints";
@use "mixins";
.badge { .badge {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -27,15 +30,15 @@
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
line-height: 130%; line-height: 130%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 32px; font-size: 32px;
} }
} }
.badge__outline { .badge__outline {
@include sectionFills(0.15); @include mixins.sectionFills(0.15);
} }
.badge__inner { .badge__inner {
@include sectionFills(); @include mixins.sectionFills();
} }
@@ -1,3 +1,5 @@
@use "mixins";
.banner { .banner {
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: 20px;
@@ -8,7 +10,7 @@
min-height: 300px; min-height: 300px;
padding: 48px 0; padding: 48px 0;
color: white; color: white;
@include sectionBackgrounds(0.8); @include mixins.sectionBackgrounds(0.8);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
@@ -1,14 +1,20 @@
@use "breakpoints";
@use "colors";
@use "fonts";
@use "mixins";
@use "variables";
html, html,
body { body {
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: $font-main, Sans-Serif; font-family: fonts.$font-main, Sans-Serif;
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 18px; font-size: 18px;
} }
@@ -41,13 +47,13 @@ html {
height: auto; height: auto;
margin-left: auto; margin-left: auto;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: none; display: none;
} }
} }
.navtoggle__icon { .navtoggle__icon {
fill: $color-font-main; fill: colors.$color-font-main;
transition: all 0.25s ease-in-out; transition: all 0.25s ease-in-out;
.mobilenav--open & { .mobilenav--open & {
@@ -62,7 +68,7 @@ html {
right: 2%; right: 2%;
width: 40px; width: 40px;
height: 40px; height: 40px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
opacity: 0.75; opacity: 0.75;
} }
@@ -76,12 +82,12 @@ a {
&.anchor { &.anchor {
display: block; display: block;
height: 0; height: 0;
padding-top: $header-height-mobile; padding-top: variables.$header-height-mobile;
margin-top: -$header-height-mobile; margin-top: -(variables.$header-height-mobile);
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding-top: $header-height-desktop; padding-top: variables.$header-height-desktop;
margin-top: -$header-height-desktop; margin-top: -(variables.$header-height-desktop);
} }
} }
} }
@@ -126,7 +132,7 @@ ul {
left: 0; left: 0;
content: "\25CF"; content: "\25CF";
font-size: 18px; font-size: 18px;
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
} }
} }
} }
@@ -144,8 +150,8 @@ p {
hr { hr {
max-width: 25%; max-width: 25%;
margin: 32px auto 32px 0; margin: 32px auto 32px 0;
color: $color-ruler; color: colors.$color-ruler;
background-color: $color-ruler; background-color: colors.$color-ruler;
height: 1px; height: 1px;
border: none; border: none;
} }
@@ -161,7 +167,7 @@ h3,
} }
.headline { .headline {
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
&--icon { &--icon {
display: flex; display: flex;
@@ -186,7 +192,7 @@ h1,
text-transform: uppercase; text-transform: uppercase;
padding-bottom: 18px; padding-bottom: 18px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 32px; font-size: 32px;
padding-bottom: 24px; padding-bottom: 24px;
} }
@@ -200,11 +206,11 @@ h1,
display: block; display: block;
font-size: 16px; font-size: 16px;
line-height: 120%; line-height: 120%;
color: $color-brand-primary; color: colors.$color-brand-primary;
text-transform: none; text-transform: none;
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 24px; font-size: 24px;
} }
@@ -221,7 +227,7 @@ h2,
padding-bottom: 16px; padding-bottom: 16px;
font-weight: 400; font-weight: 400;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 24px; font-size: 24px;
padding-bottom: 24px; padding-bottom: 24px;
} }
@@ -231,7 +237,7 @@ h3 {
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 18px; font-size: 18px;
padding-bottom: 8px; padding-bottom: 8px;
} }
@@ -243,7 +249,7 @@ h3 {
height: auto; height: auto;
padding-right: 16px; padding-right: 16px;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
width: 96px; width: 96px;
} }
} }
@@ -1,3 +1,6 @@
@use "breakpoints";
@use "colors";
.box { .box {
width: 100%; width: 100%;
} }
@@ -13,7 +16,7 @@
color: white; color: white;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
background-color: $color-brand-secondary; background-color: colors.$color-brand-secondary;
border: none; border: none;
width: 100%; width: 100%;
font-size: 20px; font-size: 20px;
@@ -23,18 +26,18 @@
.box__content { .box__content {
padding: 32px 16px; padding: 32px 16px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
} }
.box__form { .box__form {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
fill: $color-brand-primary; fill: colors.$color-brand-primary;
} }
&--top { &--top {
@@ -52,7 +55,7 @@
font-size: 16px; font-size: 16px;
line-height: 32px; line-height: 32px;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
font-size: 20px; font-size: 20px;
line-height: 32px; line-height: 32px;
@@ -66,7 +69,7 @@
.box__nav { .box__nav {
margin-top: -32px; margin-top: -32px;
padding-bottom: 32px; padding-bottom: 32px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
} }
.box__nav__item { .box__nav__item {
@@ -78,7 +81,7 @@
&--active, &--active,
&:hover { &:hover {
background-color: $color-brand-light; background-color: colors.$color-brand-light;
} }
} }
@@ -1,6 +1,9 @@
@use "breakpoints";
@use "colors";
.brands { .brands {
padding-top: 32px; padding-top: 32px;
border-top: 1px solid $color-divider; border-top: 1px solid colors.$color-divider;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
@@ -9,7 +12,7 @@
.brands__item { .brands__item {
width: 50%; width: 50%;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -1,7 +1,9 @@
$mq-breakpoints: ( @use "variables";
mobile: $viewport-width-mobile,
tablet: $viewport-width-tablet,
desktop: $viewport-width-desktop
);
@import "~sass-mq"; @forward "sass-mq" with (
$breakpoints: (
mobile: variables.$viewport-width-mobile,
tablet: variables.$viewport-width-tablet,
desktop: variables.$viewport-width-desktop
)
);
@@ -1,3 +1,7 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.button { .button {
display: inline-block; display: inline-block;
font-weight: 700; font-weight: 700;
@@ -5,7 +9,7 @@
&--default { &--default {
display: inline-block; display: inline-block;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
padding: 4px 16px; padding: 4px 16px;
} }
@@ -13,7 +17,7 @@
&--page-header { &--page-header {
display: block; display: block;
min-width: 186px; min-width: 186px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
font-size: 18px; font-size: 18px;
padding: 0 32px 0 24px; padding: 0 32px 0 24px;
@@ -26,14 +30,14 @@
display: block; display: block;
min-width: 220px; min-width: 220px;
text-align: center; text-align: center;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
@include sectionBackgroundsFromParent(); @include mixins.sectionBackgroundsFromParent();
color: white; color: white;
line-height: 48px; line-height: 48px;
border: none; border: none;
outline: none; outline: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
box-shadow: 8px 8px 8px rgba(black, 0.2); box-shadow: 8px 8px 8px rgba(black, 0.2);
font-size: 24px; font-size: 24px;
} }
@@ -43,7 +47,7 @@
width: 100%; width: 100%;
display: inline-block; display: inline-block;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: auto; width: auto;
} }
} }
@@ -56,7 +60,7 @@
&:hover { &:hover {
background: white; background: white;
color: $color-font-main; color: colors.$color-font-main;
} }
} }
@@ -65,7 +69,7 @@
} }
&--icon { &--icon {
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -1,5 +1,8 @@
@use "breakpoints";
@use "mixins";
.card { .card {
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
&:not(.card--no-form) { &:not(.card--no-form) {
padding-bottom: 48px; padding-bottom: 48px;
} }
@@ -20,12 +23,12 @@
padding: 16px; padding: 16px;
color: white; color: white;
text-transform: uppercase; text-transform: uppercase;
@include sectionBackgrounds(0.75); @include mixins.sectionBackgrounds(0.75);
transition: background-color 0.25s ease-in-out; transition: background-color 0.25s ease-in-out;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
.card:hover & { .card:hover & {
@include sectionBackgrounds(); @include mixins.sectionBackgrounds();
} }
} }
} }
@@ -33,7 +36,7 @@
.card__form { .card__form {
display: none; display: none;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
display: block; display: block;
opacity: 0; opacity: 0;
transition: opacity 0.25s ease-in-out; transition: opacity 0.25s ease-in-out;
@@ -50,7 +53,7 @@
} }
polygon { polygon {
@include sectionFills(); @include mixins.sectionFills();
} }
} }
@@ -1,21 +1,25 @@
@use "colors";
@use "mixins";
@use "variables";
.carousel { .carousel {
&--background { &--background {
background-color: $color-background; background-color: colors.$color-background;
padding-bottom: 48px; padding-bottom: 48px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@include sectionBackgrounds(0.25); @include mixins.sectionBackgrounds(0.25);
} }
.carousel__outer { .carousel__outer {
@include container($viewport-width-max); @include mixins.container(variables.$viewport-width-max);
position: relative; position: relative;
} }
.carousel__inner { .carousel__inner {
@include container($content-width-max); @include mixins.container(variables.$content-width-max);
} }
.carousel__item { .carousel__item {
@@ -36,7 +40,7 @@
height: 32px; height: 32px;
fill: white; fill: white;
@include sectionColoursFromParent; @include mixins.sectionColoursFromParent;
&--prev { &--prev {
left: -5px; left: -5px;
@@ -59,7 +63,7 @@
} }
.testimonials & { .testimonials & {
fill: $color-brand-primary; fill: colors.$color-brand-primary;
} }
} }
@@ -1,8 +1,10 @@
@use "sass:color";
//$color-brand-primary: #0084d4; //$color-brand-primary: #0084d4;
$color-brand-primary: #19a0d2; $color-brand-primary: #19a0d2;
$color-brand-secondary: #1e314b; $color-brand-secondary: #1e314b;
$color-brand-light: #8CCFE8; $color-brand-light: #8CCFE8;
$color-background: lighten(#dbdee2, 5%); $color-background: color.adjust(#dbdee2, $lightness: 5%);
$color-font-main: #1e314b; $color-font-main: #1e314b;
$color-divider: rgba($color-background, 0.8); $color-divider: rgba($color-background, 0.8);
@@ -1,3 +1,6 @@
@use "breakpoints";
@use "colors";
.configurator { .configurator {
background: url(../images/bg_configurator.jpg) no-repeat top left; background: url(../images/bg_configurator.jpg) no-repeat top left;
background-size: cover; background-size: cover;
@@ -39,7 +42,7 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: rgba($color-brand-secondary, 0.85); background-color: rgba(colors.$color-brand-secondary, 0.85);
color: white; color: white;
} }
@@ -51,7 +54,7 @@
display: none; display: none;
color: white; color: white;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
display: block; display: block;
} }
} }
@@ -83,11 +86,11 @@
text-decoration: none; text-decoration: none;
text-transform: uppercase; text-transform: uppercase;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
align-items: center; align-items: center;
} }
} }
@@ -98,12 +101,12 @@
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 700; font-weight: 700;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 30%; width: 30%;
margin-bottom: 0; margin-bottom: 0;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 20%; width: 20%;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -113,11 +116,11 @@
width: 100%; width: 100%;
padding-right: 40px; padding-right: 40px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 70%; width: 70%;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 80%; width: 80%;
} }
} }
@@ -131,11 +134,11 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 16px; font-size: 16px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
margin-bottom: 0; margin-bottom: 0;
} }
} }
@@ -151,13 +154,13 @@
text-decoration: none; text-decoration: none;
text-align: center; text-align: center;
color: white; color: white;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
border-radius: 16px; border-radius: 16px;
font-family: "fontello"; font-family: "fontello";
&.closed { &.closed {
background-color: white; background-color: white;
color: $color-brand-primary; color: colors.$color-brand-primary;
&:before { &:before {
content: '\e800'; content: '\e800';
@@ -204,7 +207,7 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
width: 50%; width: 50%;
.active & { .active & {
@@ -212,7 +215,7 @@
} }
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 25%; width: 25%;
.active & { .active & {
@@ -240,10 +243,10 @@
} }
.selected &:after { .selected &:after {
background-color: rgba($color-brand-secondary, 0.75); background-color: rgba(colors.$color-brand-secondary, 0.75);
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
&:hover:after { &:hover:after {
content: ""; content: "";
width: 100%; width: 100%;
@@ -251,7 +254,7 @@
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
background-color: rgba($color-brand-primary, 0.5); background-color: rgba(colors.$color-brand-primary, 0.5);
} }
} }
@@ -260,12 +263,12 @@
background-position: center 32px; background-position: center 32px;
background-size: 96px; background-size: 96px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
background-position: center 16px; background-position: center 16px;
background-size: 128px; background-size: 128px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
background-position: center 8px; background-position: center 8px;
background-size: 96px; background-size: 96px;
} }
@@ -382,7 +385,7 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
@@ -391,7 +394,7 @@
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 66.6666%; width: 66.6666%;
padding-right: 32px; padding-right: 32px;
} }
@@ -401,7 +404,7 @@
width: 100%; width: 100%;
padding-bottom: 16px; padding-bottom: 16px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -456,7 +459,7 @@
line-height: 32px; line-height: 32px;
text-align: center; text-align: center;
background-color: white; background-color: white;
color: $color-brand-primary; color: colors.$color-brand-primary;
border-radius: 16px; border-radius: 16px;
font-family: 'fontello'; font-family: 'fontello';
@@ -1,3 +1,6 @@
// .headline* live in _base.scss; @extend needs that module loaded explicitly.
@use "base";
.cookiefirst { .cookiefirst {
h2 { h2 {
@extend .headline; @extend .headline;
@@ -1,10 +1,14 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.form { .form {
margin: 0; margin: 0;
padding: 0; padding: 0;
position: relative; position: relative;
max-width: 100%; max-width: 100%;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
&:not(&--horizontal) { &:not(&--horizontal) {
max-width: 75%; max-width: 75%;
} }
@@ -16,7 +20,7 @@
&--horizontal { &--horizontal {
width: 100%; width: 100%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -42,7 +46,7 @@
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: $color-brand-light; background-color: colors.$color-brand-light;
color: white; color: white;
border-radius: 8px; border-radius: 8px;
} }
@@ -50,7 +54,7 @@
&--mobile { &--mobile {
display: block; display: block;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: none; display: none;
} }
} }
@@ -58,7 +62,7 @@
&--tablet { &--tablet {
display: none; display: none;
@include mq($from: tablet, $until: desktop) { @include breakpoints.mq($from: tablet, $until: desktop) {
display: block; display: block;
} }
} }
@@ -66,7 +70,7 @@
&--desktop { &--desktop {
display: none; display: none;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
display: block; display: block;
} }
} }
@@ -79,7 +83,7 @@
padding-top: 20px; padding-top: 20px;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -88,20 +92,20 @@
.form__item__error { .form__item__error {
padding: 8px; padding: 8px;
font-size: 90%; font-size: 90%;
color: $color-formerror; color: colors.$color-formerror;
} }
.form__field { .form__field {
@include nooutline; @include mixins.nooutline;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
width: 100%; width: 100%;
padding: 8px 16px; padding: 8px 16px;
color: white; color: white;
background-color: $color-brand-light; background-color: colors.$color-brand-light;
.form--box & { .form--box & {
background-color: $color-brand-light; background-color: colors.$color-brand-light;
color: white; color: white;
} }
@@ -114,10 +118,10 @@
display: block; display: block;
padding: 8px; padding: 8px;
width: 100%; width: 100%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 50%; width: 50%;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -127,7 +131,7 @@
} }
.has-errors & { .has-errors & {
border: 1px solid $color-formerror; border: 1px solid colors.$color-formerror;
} }
} }
@@ -158,7 +162,7 @@
display: block; display: block;
width: 100%; width: 100%;
color: white; color: white;
background-color: $color-brand-secondary; background-color: colors.$color-brand-secondary;
border: none; border: none;
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
@@ -166,7 +170,7 @@
padding: 8px 32px; padding: 8px 32px;
margin-bottom: 16px; margin-bottom: 16px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: inline-block; display: inline-block;
width: auto; width: auto;
margin-bottom: 0; margin-bottom: 0;
@@ -189,10 +193,10 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: rgba($color-divider, 0.75); background-color: rgba(colors.$color-divider, 0.75);
.form--box & { .form--box & {
background-color: rgba($color-brand-primary, 0.75); background-color: rgba(colors.$color-brand-primary, 0.75);
} }
} }
@@ -1,7 +1,10 @@
@use "breakpoints";
@use "colors";
.helpernav { .helpernav {
display: none; display: none;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
display: block; display: block;
position: relative; position: relative;
} }
@@ -24,7 +27,7 @@
z-index: 10; z-index: 10;
min-width: 300px; min-width: 300px;
padding: 16px 32px; padding: 16px 32px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
} }
.helpernav__item { .helpernav__item {
@@ -32,7 +35,7 @@
&:hover:not(.helpernav__item--disabled) { &:hover:not(.helpernav__item--disabled) {
& > .helpernav__link { & > .helpernav__link {
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
} }
& > .helpernav__subnav { & > .helpernav__subnav {
@@ -1,11 +1,16 @@
@use "breakpoints";
@use "colors";
@use "mixins";
@use "variables";
.herounit { .herounit {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: $viewport-width-max; max-width: variables.$viewport-width-max;
margin: 0 auto; margin: 0 auto;
&--indent { &--indent {
max-width: $content-width-max - 16px; max-width: variables.$content-width-max - 16px;
} }
} }
@@ -16,22 +21,22 @@
height: 240px; height: 240px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
height: 384px; height: 384px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
height: 420px; height: 420px;
} }
.herounit--large & { .herounit--large & {
height: 300px; height: 300px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
height: 480px; height: 480px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
height: 700px; height: 700px;
} }
} }
@@ -39,7 +44,7 @@
.herounit--indent & { .herounit--indent & {
height: 320px; height: 320px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
height: 480px; height: 480px;
} }
} }
@@ -48,7 +53,7 @@
.herounit__mask { .herounit__mask {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
top: 0; top: 0;
@@ -67,9 +72,9 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 32px; padding: 32px;
background-color: rgba($color-brand-secondary, 0.2); background-color: rgba(colors.$color-brand-secondary, 0.2);
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
background-color: transparent; background-color: transparent;
padding: 64px 16px 0 96px; padding: 64px 16px 0 96px;
max-width: 45%; max-width: 45%;
@@ -83,7 +88,7 @@
font-size: 32px; font-size: 32px;
line-height: 95%; line-height: 95%;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
font-size: 48px; font-size: 48px;
} }
@@ -101,10 +106,10 @@
span { span {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: $color-brand-primary; color: colors.$color-brand-primary;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
font-size: 56px; font-size: 56px;
} }
} }
@@ -120,7 +125,7 @@
color: white; color: white;
text-transform: none; text-transform: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 20px; font-size: 20px;
} }
@@ -128,12 +133,12 @@
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 24px; font-size: 24px;
padding-bottom: 48px; padding-bottom: 48px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
font-size: 32px; font-size: 32px;
padding-bottom: 96px; padding-bottom: 96px;
} }
@@ -146,7 +151,7 @@
line-height: 120%; line-height: 120%;
color: white; color: white;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -154,7 +159,7 @@
.herounit__icon { .herounit__icon {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
@@ -173,11 +178,11 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
top: 85%; top: 85%;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@@ -190,34 +195,34 @@
height: 48px; height: 48px;
border: none; border: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
} }
& > polygon { & > polygon {
fill: $color-brand-primary; fill: colors.$color-brand-primary;
@include sectionFills(); @include mixins.sectionFills();
} }
} }
#gradient__from { #gradient__from {
stop-color: $color-brand-secondary; stop-color: colors.$color-brand-secondary;
} }
#gradient__to { #gradient__to {
.section-default & { .section-default & {
stop-color: $color-brand-primary; stop-color: colors.$color-brand-primary;
} }
.section-incentives & { .section-incentives & {
stop-color: $color-incentives; stop-color: colors.$color-incentives;
} }
.section-teambuilding & { .section-teambuilding & {
stop-color: $color-teambuilding; stop-color: colors.$color-teambuilding;
} }
.secolor-meeting & { .secolor-meeting & {
stop-color: $color-meetings; stop-color: colors.$color-meetings;
} }
.secolor-events & { .secolor-events & {
stop-color: $color-events; stop-color: colors.$color-events;
} }
} }
@@ -1,9 +1,12 @@
@use "breakpoints";
@use "colors";
.iconbar { .iconbar {
@apply hidden lg:block fixed z-100 top-[30vh] right-0 overflow-hidden; @apply hidden lg:block fixed z-100 top-[30vh] right-0 overflow-hidden;
} }
.iconbar { .iconbar {
display: none; display: none;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
display: block; display: block;
} }
position: fixed; position: fixed;
@@ -20,7 +23,7 @@
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
background-color: $color-brand-light; background-color: colors.$color-brand-light;
margin-bottom: 2px; margin-bottom: 2px;
&:hover { &:hover {
@@ -30,7 +33,7 @@
.iconbar__icon { .iconbar__icon {
padding: 4px; padding: 4px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
svg { svg {
display: block; display: block;
@@ -1,3 +1,6 @@
@use "breakpoints";
@use "mixins";
.iconnav { .iconnav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -21,7 +24,7 @@
opacity: 1; opacity: 1;
} }
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
width: 25%; width: 25%;
} }
} }
@@ -36,5 +39,5 @@
.iconnav__label { .iconnav__label {
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
@include sectionColours(); @include mixins.sectionColours();
} }
@@ -1,7 +1,10 @@
@use "breakpoints";
@use "mixins";
main { main {
padding-top: 96px; padding-top: 96px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding-top: 140px; padding-top: 140px;
} }
} }
@@ -10,7 +13,7 @@ main {
width: 100%; width: 100%;
padding-top: 32px; padding-top: 32px;
padding-bottom: 32px; padding-bottom: 32px;
@include clearfix(); @include mixins.clearfix();
&.nopad { &.nopad {
&--bottom { &--bottom {
@@ -23,18 +26,18 @@ main {
} }
.content__title { .content__title {
@include container(); @include mixins.container();
text-align: center; text-align: center;
} }
.content__inner { .content__inner {
@include container(); @include mixins.container();
.content--has-sidebar & { .content--has-sidebar & {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
@@ -45,40 +48,40 @@ main {
padding: 0 0 32px; padding: 0 0 32px;
overflow: hidden; overflow: hidden;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding: 0; padding: 0;
} }
} }
.content__plain { .content__plain {
@include clearfix(); @include mixins.clearfix();
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding-right: 96px; padding-right: 96px;
} }
} }
.content__background { .content__background {
@include clearfix(); @include mixins.clearfix();
@include sectionBackgrounds(0.15); @include mixins.sectionBackgrounds(0.15);
padding: 24px 48px 24px 32px; padding: 24px 48px 24px 32px;
} }
.content__float { .content__float {
@include clearfix(); @include mixins.clearfix();
} }
.content__sidebar { .content__sidebar {
width: 100%; width: 100%;
padding: 32px 0; padding: 32px 0;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
flex: 0 0 320px; flex: 0 0 320px;
padding: 0 0 0 32px; padding: 0 0 0 32px;
} }
} }
.content__form { .content__form {
@include sectionFills(0.15); @include mixins.sectionFills(0.15);
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
@@ -93,7 +96,7 @@ main {
.desktop-only { .desktop-only {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -1,4 +1,6 @@
@use "sass:math"; @use "sass:math";
@use "variables" as *;
@use "colors" as *;
@mixin clearfix { @mixin clearfix {
&::after { &::after {
@@ -1,3 +1,6 @@
@use "breakpoints";
@use "colors";
.mobilenav { .mobilenav {
position: fixed; position: fixed;
z-index: 50; z-index: 50;
@@ -7,10 +10,10 @@
height: 0; height: 0;
padding-top: 96px; padding-top: 96px;
overflow: hidden; overflow: hidden;
background: rgba($color-brand-secondary, 0.9); background: rgba(colors.$color-brand-secondary, 0.9);
transition: height 0.25s ease-in-out; transition: height 0.25s ease-in-out;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
display: none; display: none;
} }
@@ -1,3 +1,5 @@
@use "breakpoints";
.news-entry { .news-entry {
width: 100%; width: 100%;
&:not(:last-child) { &:not(:last-child) {
@@ -13,7 +15,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
flex-direction: row; flex-direction: row;
} }
} }
@@ -22,7 +24,7 @@
width: 100%; width: 100%;
margin-bottom: 8px; margin-bottom: 8px;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 50%; width: 50%;
} }
} }
@@ -39,7 +41,7 @@
.news-entry__teaser { .news-entry__teaser {
width: 100%; width: 100%;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
padding-left: 24px; padding-left: 24px;
width: 50%; width: 50%;
} }
@@ -1,3 +1,5 @@
@use "breakpoints";
.offerlist { .offerlist {
position: relative; position: relative;
} }
@@ -13,7 +15,7 @@
} }
.offerlist__teasers { .offerlist__teasers {
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -24,13 +26,13 @@
text-decoration: none; text-decoration: none;
margin-bottom: 32px; margin-bottom: 32px;
display: block; display: block;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
display: flex; display: flex;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 50%; width: 50%;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -1,25 +1,30 @@
@use "breakpoints";
@use "colors";
@use "mixins";
@use "variables";
.page-footer { .page-footer {
width: 100%; width: 100%;
background: linear-gradient(to right, $color-brand-secondary, $color-brand-primary); background: linear-gradient(to right, colors.$color-brand-secondary, colors.$color-brand-primary);
color: white; color: white;
padding-top: 16px; padding-top: 16px;
padding-bottom: 16px; padding-bottom: 16px;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
padding-top: 48px; padding-top: 48px;
padding-bottom: 48px; padding-bottom: 48px;
} }
} }
.page-footer__inner { .page-footer__inner {
@include container($content-width-max); @include mixins.container(variables.$content-width-max);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
border-bottom: 1px solid white; border-bottom: 1px solid white;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding-bottom: 48px; padding-bottom: 48px;
} }
} }
@@ -33,14 +38,14 @@
text-align: left; text-align: left;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 20%; width: 20%;
} }
&--hide-mobile { &--hide-mobile {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -60,14 +65,14 @@
} }
.page-footer__bottom { .page-footer__bottom {
@include container($content-width-max); @include mixins.container(variables.$content-width-max);
padding-top: 32px; padding-top: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: space-around;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
} }
@@ -76,7 +81,7 @@
.page-footer__bottom__column { .page-footer__bottom__column {
width: 100%; width: 100%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -86,7 +91,7 @@
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
justify-content: flex-start; justify-content: flex-start;
} }
} }
@@ -106,7 +111,7 @@
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
text-align: left; text-align: left;
} }
} }
@@ -115,7 +120,7 @@
text-align: center; text-align: center;
padding-top: 32px; padding-top: 32px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
text-align: right; text-align: right;
padding-top: 0; padding-top: 0;
} }
@@ -138,7 +143,7 @@
text-align: center; text-align: center;
padding-top: 32px; padding-top: 32px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
text-align: left; text-align: left;
padding-top: 0; padding-top: 0;
} }
@@ -1,3 +1,7 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.page-header { .page-header {
width: 100%; width: 100%;
position: fixed; position: fixed;
@@ -5,28 +9,28 @@
left: 0; left: 0;
z-index: 1000; z-index: 1000;
background: white; background: white;
box-shadow: 0 2px 2px rgba($color-background, 0.5); box-shadow: 0 2px 2px rgba(colors.$color-background, 0.5);
} }
.page-header__bar { .page-header__bar {
background: linear-gradient(to right, $color-brand-primary 50%, $color-brand-secondary); background: linear-gradient(to right, colors.$color-brand-primary 50%, colors.$color-brand-secondary);
} }
.page-header__bar__main { .page-header__bar__main {
@include container(); @include mixins.container();
color: white; color: white;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
justify-content: space-between; justify-content: space-between;
} }
} }
.page-header__bar__search { .page-header__bar__search {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
width: 50%; width: 50%;
} }
@@ -37,7 +41,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 50%; width: 50%;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -53,14 +57,14 @@
.page-header__bar__divider { .page-header__bar__divider {
display: none; display: none;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
display: inline-block; display: inline-block;
padding: 12px 8px; padding: 12px 8px;
} }
} }
.page-header__main { .page-header__main {
@include container(); @include mixins.container();
padding: 16px; padding: 16px;
display: flex; display: flex;
display: -ms-flexbox; display: -ms-flexbox;
@@ -68,11 +72,11 @@
align-items: center; align-items: center;
-ms-flex-align: center; -ms-flex-align: center;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding: 32px 16px 24px; padding: 32px 16px 24px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
-ms-flex-align: end; -ms-flex-align: end;
@@ -98,7 +102,7 @@
.page-header__sitenav { .page-header__sitenav {
display: none; display: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
display: block; display: block;
} }
} }
@@ -106,7 +110,7 @@
.page-header__button { .page-header__button {
display: none; display: none;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
position: relative; position: relative;
display: block; display: block;
min-width: 220px; min-width: 220px;
@@ -128,6 +132,6 @@
border: none; border: none;
& > polygon { & > polygon {
fill: $color-brand-primary; fill: colors.$color-brand-primary;
} }
} }
@@ -1,3 +1,5 @@
@use "colors";
.paginator { .paginator {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -10,7 +12,7 @@
.paginator__item { .paginator__item {
display: inline-block; display: inline-block;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
padding: 4px; padding: 4px;
text-decoration: none; text-decoration: none;
@@ -19,8 +21,8 @@
} }
&--current { &--current {
border: 1px solid $color-brand-primary; border: 1px solid colors.$color-brand-primary;
background-color: white; background-color: white;
color: $color-brand-primary; color: colors.$color-brand-primary;
} }
} }
@@ -1,24 +1,27 @@
@use "breakpoints";
@use "mixins";
.searchresult {} .searchresult {}
.searchresult__info { .searchresult__info {
@include container(); @include mixins.container();
} }
.searchresult-item { .searchresult-item {
text-decoration: none; text-decoration: none;
margin-bottom: 32px; margin-bottom: 32px;
display: block; display: block;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
display: flex; display: flex;
} }
} }
.searchresult-item__card { .searchresult-item__card {
width: 100%; width: 100%;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
width: 240px; width: 240px;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -27,8 +30,8 @@
margin: 0 8px; margin: 0 8px;
padding: 32px; padding: 32px;
color: white; color: white;
@include sectionBackgrounds(); @include mixins.sectionBackgrounds();
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
@@ -1,5 +1,8 @@
@use "breakpoints";
@use "mixins";
.sitenav { .sitenav {
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
padding-right: 64px; padding-right: 64px;
} }
} }
@@ -25,7 +28,7 @@
&--active, &--active,
&:hover { &:hover {
border-bottom: 3px solid; border-bottom: 3px solid;
@include sectionColours(); @include mixins.sectionColours();
} }
} }
@@ -1,3 +1,5 @@
@use "breakpoints";
.slider {} .slider {}
.slider__arrow { .slider__arrow {
@@ -38,7 +40,7 @@
top: 30%; top: 30%;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
@@ -1,11 +1,14 @@
@use "breakpoints";
@use "mixins";
.statement { .statement {
@include container(); @include mixins.container();
padding-top: 32px; padding-top: 32px;
padding-bottom: 48px; padding-bottom: 48px;
} }
.statement__inner { .statement__inner {
@include sectionBackgrounds(); @include mixins.sectionBackgrounds();
box-shadow: 0 5px 10px rgba(black, 0.2); box-shadow: 0 5px 10px rgba(black, 0.2);
text-align: center; text-align: center;
padding-top: 48px; padding-top: 48px;
@@ -18,7 +21,7 @@
font-size: 24px; font-size: 24px;
line-height: 105%; line-height: 105%;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 48px; font-size: 48px;
} }
} }
@@ -1,3 +1,6 @@
@use "breakpoints";
@use "colors";
.team { .team {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -12,11 +15,11 @@
max-width: 400px; max-width: 400px;
padding: 0 16px 64px; padding: 0 16px 64px;
@include mq($until: desktop) { @include breakpoints.mq($until: desktop) {
margin: 0 auto; margin: 0 auto;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 50%; width: 50%;
max-width: 100%; max-width: 100%;
} }
@@ -57,10 +60,10 @@
.team__position { .team__position {
display: block; display: block;
color: $color-brand-primary; color: colors.$color-brand-primary;
} }
.team__contact__link { .team__contact__link {
text-decoration: none; text-decoration: none;
color: $color-brand-primary; color: colors.$color-brand-primary;
} }
@@ -1,12 +1,15 @@
@use "breakpoints";
@use "mixins";
.teasers { .teasers {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@include sectionBackgrounds(0.15); @include mixins.sectionBackgrounds(0.15);
margin: 0 -16px 0; margin: 0 -16px 0;
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
flex-direction: row; flex-direction: row;
} }
} }
@@ -19,15 +22,15 @@
cursor: pointer; cursor: pointer;
} }
@include mq($until: tablet) { @include breakpoints.mq($until: tablet) {
padding-bottom: 16px; padding-bottom: 16px;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 50%; width: 50%;
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 33.3333%; width: 33.3333%;
} }
} }
@@ -40,12 +43,12 @@
} }
.teaser__caption { .teaser__caption {
@include sectionBackgroundsFromParent(); @include mixins.sectionBackgroundsFromParent();
padding: 16px 16px 0; padding: 16px 16px 0;
} }
.teaser__form { .teaser__form {
@include sectionFills(); @include mixins.sectionFills();
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;
@@ -1,17 +1,20 @@
@use "breakpoints";
@use "colors";
.testimonial { .testimonial {
width: 100%; width: 100%;
padding: 0 32px; padding: 0 32px;
text-align: center; text-align: center;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 80%; font-size: 80%;
width: 50%; width: 50%;
&:not(:last-child) { &:not(:last-child) {
border-right: 1px solid $color-divider; border-right: 1px solid colors.$color-divider;
} }
} }
@include mq($from: desktop) { @include breakpoints.mq($from: desktop) {
width: 25%; width: 25%;
} }
} }
@@ -1,3 +1,7 @@
@use "breakpoints";
@use "colors";
@use "mixins";
.tiles { .tiles {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -15,7 +19,7 @@
padding: 16px; padding: 16px;
text-decoration: none; text-decoration: none;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 50%; width: 50%;
padding: 16px; padding: 16px;
} }
@@ -26,12 +30,12 @@
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@include mq($from: mobile) { @include breakpoints.mq($from: mobile) {
min-height: 250px; min-height: 250px;
padding: 32px; padding: 32px;
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding: 32px 32px 0; padding: 32px 32px 0;
min-height: 375px; min-height: 375px;
} }
@@ -42,33 +46,33 @@
color: white; color: white;
text-align: center; text-align: center;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
width: 70%; width: 70%;
margin: 0 auto; margin: 0 auto;
} }
} }
.tile__caption__inner { .tile__caption__inner {
@include sectionBackgrounds(0.75); @include mixins.sectionBackgrounds(0.75);
width: 100%; width: 100%;
padding: 24px 0; padding: 24px 0;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
padding: 32px 0; padding: 32px 0;
.tile:hover .section-incentives & { .tile:hover .section-incentives & {
background-color: $color-incentives; background-color: colors.$color-incentives;
} }
.tile:hover .section-teambuilding & { .tile:hover .section-teambuilding & {
background-color: $color-teambuilding; background-color: colors.$color-teambuilding;
} }
.tile:hover .section-meetings & { .tile:hover .section-meetings & {
background-color: $color-meetings; background-color: colors.$color-meetings;
} }
.tile:hover .section-events & { .tile:hover .section-events & {
background-color: $color-events; background-color: colors.$color-events;
} }
} }
} }
@@ -77,7 +81,7 @@
display: none; display: none;
padding: 16px 32px 0; padding: 16px 32px 0;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
.tile:hover & { .tile:hover & {
display: block; display: block;
} }
@@ -91,10 +95,10 @@
margin-top: -1px; margin-top: -1px;
polygon { polygon {
@include sectionFills(); @include mixins.sectionFills();
} }
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
.tile:hover & { .tile:hover & {
display: block; display: block;
} }
@@ -114,7 +118,7 @@
font-weight: 700; font-weight: 700;
min-height: 54px; min-height: 54px;
@include mq($from: tablet) { @include breakpoints.mq($from: tablet) {
font-size: 24px; font-size: 24px;
} }
} }
@@ -1,5 +1,7 @@
@use "mixins";
.video { .video {
@include container(); @include mixins.container();
padding-top: 32px; padding-top: 32px;
padding-bottom: 32px; padding-bottom: 32px;
} }
@@ -1,39 +1,39 @@
@import "variables"; @use "variables";
@import "mixins"; @use "mixins";
@import "breakpoints"; @use "breakpoints";
@import "colors"; @use "colors";
@import "fonts"; @use "fonts";
@import "base"; @use "base";
@import "buttons"; @use "buttons";
@import "page-header"; @use "page-header";
@import "page-footer"; @use "page-footer";
@import "layout"; @use "layout";
@import "sitenav"; @use "sitenav";
@import "helpernav"; @use "helpernav";
@import "mobilenav"; @use "mobilenav";
@import "herounit"; @use "herounit";
@import "carousel"; @use "carousel";
@import "slider"; @use "slider";
@import "configurator"; @use "configurator";
@import "statement"; @use "statement";
@import "testimonial"; @use "testimonial";
@import "brands"; @use "brands";
@import "iconbar"; @use "iconbar";
@import "tiles"; @use "tiles";
@import "card"; @use "card";
@import "box"; @use "box";
@import "banner"; @use "banner";
@import "iconnav"; @use "iconnav";
@import "forms"; @use "forms";
@import "teasers"; @use "teasers";
@import "badge"; @use "badge";
@import "team"; @use "team";
@import "search"; @use "search";
@import "offerlist"; @use "offerlist";
@import "paginator"; @use "paginator";
@import "news"; @use "news";
@import "datepicker"; @use "datepicker";
@import "cookiefirst"; @use "cookiefirst";
@import "video"; @use "video";
@@ -1,18 +1,14 @@
@import "mixins"; @use "mixins";
@import "colors"; @use "colors";
@font-face { @font-face {
font-family: 'Cairo'; font-family: 'Cairo';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('../fonts/cairo-v2-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
src: local('Cairo'), local('Cairo-Regular'), src: local('Cairo'), local('Cairo-Regular'),
url('../fonts/cairo-v2-latin-ext_latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('../fonts/cairo-v2-latin-ext_latin-regular.woff2') format('woff2'),
url('../fonts/cairo-v2-latin-ext_latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ url('../fonts/cairo-v2-latin-ext_latin-regular.woff') format('woff');
url('../fonts/cairo-v2-latin-ext_latin-regular.woff') format('woff'), /* Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('../fonts/cairo-v2-latin-ext_latin-regular.svg#Cairo') format('svg'); /* Legacy iOS */
} }
@font-face { @font-face {
@@ -20,13 +16,9 @@
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('../fonts/cairo-v2-latin-ext_latin-700.eot'); /* IE9 Compat Modes */
src: local('Cairo Bold'), local('Cairo-Bold'), src: local('Cairo Bold'), local('Cairo-Bold'),
url('../fonts/cairo-v2-latin-ext_latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('../fonts/cairo-v2-latin-ext_latin-700.woff2') format('woff2'),
url('../fonts/cairo-v2-latin-ext_latin-700.woff2') format('woff2'), /* Super Modern Browsers */ url('../fonts/cairo-v2-latin-ext_latin-700.woff') format('woff');
url('../fonts/cairo-v2-latin-ext_latin-700.woff') format('woff'), /* Modern Browsers */
url('../fonts/cairo-v2-latin-ext_latin-700.ttf') format('truetype'), /* Safari, Android, iOS */
url('../fonts/cairo-v2-latin-ext_latin-700.svg#Cairo') format('svg'); /* Legacy iOS */
} }
$font-main: 'Cairo'; $font-main: 'Cairo';
@@ -34,12 +26,8 @@ $font-main: 'Cairo';
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
font-display: swap; font-display: swap;
src: url('../fonts/fontello.eot?39986861'); src: url('../fonts/fontello.woff2?39986861') format('woff2'),
src: url('../fonts/fontello.eot?39986861#iefix') format('embedded-opentype'), url('../fonts/fontello.woff?39986861') format('woff');
url('../fonts/fontello.woff2?39986861') format('woff2'),
url('../fonts/fontello.woff?39986861') format('woff'),
url('../fonts/fontello.ttf?39986861') format('truetype'),
url('../fonts/fontello.svg?39986861#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -123,7 +111,7 @@ ul {
left: 0; left: 0;
content: "\25CF"; content: "\25CF";
font-size: 18px; font-size: 18px;
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
} }
} }
} }
@@ -141,8 +129,8 @@ p {
hr { hr {
max-width: 25%; max-width: 25%;
margin: 32px auto 32px 0; margin: 32px auto 32px 0;
color: $color-ruler; color: colors.$color-ruler;
background-color: $color-ruler; background-color: colors.$color-ruler;
height: 1px; height: 1px;
border: none; border: none;
} }
@@ -158,7 +146,7 @@ h3,
} }
.headline { .headline {
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
&--icon { &--icon {
display: flex; display: flex;
@@ -192,9 +180,9 @@ h1,
display: block; display: block;
font-size: 16px; font-size: 16px;
line-height: 120%; line-height: 120%;
color: $color-brand-primary; color: colors.$color-brand-primary;
text-transform: none; text-transform: none;
@include sectionColoursFromParent(); @include mixins.sectionColoursFromParent();
&--box { &--box {
color: white; color: white;
@@ -229,7 +217,7 @@ h3 {
&--default { &--default {
display: inline-block; display: inline-block;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
padding: 4px 16px; padding: 4px 16px;
} }
@@ -237,7 +225,7 @@ h3 {
&--page-header { &--page-header {
display: block; display: block;
min-width: 186px; min-width: 186px;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
font-size: 18px; font-size: 18px;
padding: 0 32px 0 24px; padding: 0 32px 0 24px;
@@ -250,8 +238,8 @@ h3 {
display: block; display: block;
min-width: 220px; min-width: 220px;
text-align: center; text-align: center;
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
@include sectionBackgroundsFromParent(); @include mixins.sectionBackgroundsFromParent();
color: white; color: white;
line-height: 48px; line-height: 48px;
border: none; border: none;
@@ -271,7 +259,7 @@ h3 {
&:hover { &:hover {
background: white; background: white;
color: $color-font-main; color: colors.$color-font-main;
} }
} }
@@ -280,7 +268,7 @@ h3 {
} }
&--icon { &--icon {
background-color: $color-brand-primary; background-color: colors.$color-brand-primary;
color: white; color: white;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -13,9 +13,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
class ApiClient class ApiClient
{ {
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
public const CALENDAR_MODE_DAYS = 'days'; public const CALENDAR_MODE_DAYS = 'days';
public const CALENDAR_MODE_RANGES = 'ranges'; public const CALENDAR_MODE_RANGES = 'ranges';
private ?array $configuration = null;
public function getCalendar( public function getCalendar(
$hotelCode, $hotelCode,
\DateTimeInterface $dateFrom, \DateTimeInterface $dateFrom,
@@ -84,7 +92,7 @@ class ApiClient
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products'); $config = $this->getConfiguration();
$baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? '')); $baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['bpnConnectApiKey'] ?? '')); $apiKey = trim((string)($config['bpnConnectApiKey'] ?? ''));
@@ -95,10 +103,38 @@ class ApiClient
throw new ApiException('Bpn Connect API key is not configured.'); throw new ApiException('Bpn Connect API key is not configured.');
} }
return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', [ $options = [
'headers' => [ 'headers' => [
'X-API-KEY' => $apiKey, 'X-API-KEY' => $apiKey,
], ],
]); ];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['bpnConnectApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['bpnConnectApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
} }
} }
@@ -28,10 +28,10 @@ namespace EP\EpProducts\Command;
***************************************************************/ ***************************************************************/
use EP\EpProducts\Service\DateImportService; use EP\EpProducts\Service\DateImportService;
use EP\EpProducts\Service\ProductImportSourceFactory;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Service\CacheService; use TYPO3\CMS\Extbase\Service\CacheService;
@@ -53,21 +53,29 @@ class DateCommandController extends Command
*/ */
protected $configurationManager; protected $configurationManager;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/** /**
* @param DateImportService $importService * @param DateImportService $importService
* @param CacheService $cacheService * @param CacheService $cacheService
* @param ConfigurationManagerInterface $configurationManager * @param ConfigurationManagerInterface $configurationManager
* @param ProductImportSourceFactory $importSourceFactory
*/ */
public function __construct public function __construct
( (
DateImportService $importService, DateImportService $importService,
CacheService $cacheService, CacheService $cacheService,
ConfigurationManagerInterface $configurationManager ConfigurationManagerInterface $configurationManager,
ProductImportSourceFactory $importSourceFactory
) )
{ {
$this->dateImportService = $importService; $this->dateImportService = $importService;
$this->cacheService = $cacheService; $this->cacheService = $cacheService;
$this->configurationManager = $configurationManager; $this->configurationManager = $configurationManager;
$this->importSourceFactory = $importSourceFactory;
parent::__construct(); parent::__construct();
} }
@@ -79,8 +87,15 @@ class DateCommandController extends Command
*/ */
public function execute(InputInterface $input, OutputInterface $output): int public function execute(InputInterface $input, OutputInterface $output): int
{ {
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport'); $importSource = $this->importSourceFactory->get();
$count = $this->dateImportService->import($path); $import = $importSource->acquire();
if ($import === null) {
$output->writeln('No new import available.');
return 0;
}
$count = $this->dateImportService->import($import->getRows());
$importSource->markImported($import);
$output->writeln(sprintf('%d rows imported.', $count )); $output->writeln(sprintf('%d rows imported.', $count ));
$settings = $this->getSettings(); $settings = $this->getSettings();
$resellerPageUid = $settings['resellerExportPageUid']; $resellerPageUid = $settings['resellerExportPageUid'];
@@ -0,0 +1,165 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's backchannel endpoints under /api/content/.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md.
*/
class ContentFeedApi
{
public const PATH_PREFIX = '/api/content/';
private const MANIFEST_CACHE_IDENTIFIER = 'content_feed_manifest';
// The consumer polls every 15 minutes, so this caps the build at once per poll however
// often it retries. Edits reach the chatbot within about 25 minutes.
private const MANIFEST_CACHE_LIFETIME = 600;
private const MAX_RECORDS_PER_REQUEST = 100;
private const DISABLED_RETRY_AFTER = 3600;
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ManifestBuilder
*/
private $manifestBuilder;
/**
* @var RecordSerializer
*/
private $recordSerializer;
/**
* @var FrontendInterface
*/
private $cache;
public function __construct(
ApiKeyAuthenticator $authenticator,
ContentFeedConfiguration $configuration,
ManifestBuilder $manifestBuilder,
RecordSerializer $recordSerializer,
CacheManager $cacheManager
) {
$this->authenticator = $authenticator;
$this->configuration = $configuration;
$this->manifestBuilder = $manifestBuilder;
$this->recordSerializer = $recordSerializer;
$this->cache = $cacheManager->getCache('ep_products_cache');
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if (!$this->authenticator->isAuthorized($request)) {
return $this->error('Invalid or missing api key', 403);
}
if (!$this->configuration->isEnabled()) {
return self::disabledResponse();
}
if ([] === $this->configuration->getSites()) {
// An empty feed would read as "everything was deleted". Refuse instead.
return $this->error('Content feed has no sites configured', 503);
}
$path = substr($request->getUri()->getPath(), strlen(self::PATH_PREFIX));
if ('manifest' === $path) {
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
}
if ('records' === $path) {
return $this->handleRecordsRequest($request);
}
return $this->error('Not found', 404);
}
/**
* Uids that are not (or no longer) eligible are left out rather than answered with an error:
* the manifest may have changed between the consumer's two calls, and it reads a requested
* but absent uid as deleted.
*/
private function handleRecordsRequest(ServerRequestInterface $request): ResponseInterface
{
$type = (string) ($request->getQueryParams()['type'] ?? '');
if (!isset(RecordFinder::TYPES[$type])) {
return $this->error('Unknown type, expected one of: ' . implode(', ', array_keys(RecordFinder::TYPES)), 400);
}
$uids = array_values(array_unique(array_filter(
GeneralUtility::intExplode(',', (string) ($request->getQueryParams()['uids'] ?? ''), true),
function (int $uid): bool {
return $uid > 0;
}
)));
if ([] === $uids) {
return $this->error('No uids given', 400);
}
if (count($uids) > self::MAX_RECORDS_PER_REQUEST) {
return $this->error(sprintf('At most %d uids per request', self::MAX_RECORDS_PER_REQUEST), 400);
}
return new JsonResponse([
'type' => $type,
'items' => $this->recordSerializer->serialize($type, $uids),
], 200, [], self::JSON_FLAGS);
}
private function getManifest(): array
{
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
if (!is_array($manifest)) {
$manifest = $this->manifestBuilder->build();
$this->cache->set(self::MANIFEST_CACHE_IDENTIFIER, $manifest, [], self::MANIFEST_CACHE_LIFETIME);
}
return $manifest;
}
/**
* The kill switch: contentFeedEnabled = 0 in the extension configuration, for when the host
* is struggling. The consumer stops its run, keeps everything it has and tries again later -
* nothing is deleted on the chatbot side.
*/
public static function disabledResponse(): ResponseInterface
{
return new JsonResponse(
['message' => 'Content feed is switched off'],
503,
['Retry-After' => (string) self::DISABLED_RETRY_AFTER],
self::JSON_FLAGS
);
}
private function error(string $message, int $status): ResponseInterface
{
return new JsonResponse(['message' => $message], $status, [], self::JSON_FLAGS);
}
}
@@ -0,0 +1,75 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's extension configuration.
*
* Missing keys fall back to "feed is on, but covers nothing": a key added to ext_conf_template.txt
* is absent until the extension configuration is saved in the backend, and an empty feed is
* refused by the consumer rather than read as "everything was deleted".
*/
class ContentFeedConfiguration
{
/**
* @var array
*/
private $configuration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->configuration = (array) $extensionConfiguration->get('ep_products');
}
public function isEnabled(): bool
{
return (bool) ($this->configuration['contentFeedEnabled'] ?? true);
}
/**
* @return string[] site identifiers
*/
public function getSites(): array
{
return GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedSites'] ?? ''), true);
}
/**
* @return int[] language ids; the default language when unset
*/
public function getLanguages(): array
{
$languages = GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedLanguages'] ?? '0'), true);
return [] === $languages ? [0] : $languages;
}
/**
* @return int[] page uids whose whole subtree stays out of the feed
*/
public function getExcludedPages(): array
{
return GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedExcludedPages'] ?? ''), true);
}
/**
* @return array<string, int> site identifier => news detail page uid
*/
public function getNewsDetailPages(): array
{
$pages = [];
foreach (GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedNewsDetailPages'] ?? ''), true) as $entry) {
$parts = GeneralUtility::trimExplode(':', $entry, true);
if (2 === count($parts) && (int) $parts[1] > 0) {
$pages[$parts[0]] = (int) $parts[1];
}
}
return $pages;
}
}
@@ -0,0 +1,379 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* Builds GET /api/content/manifest: everything the content feed publishes, with a modification
* marker per item and no bodies. The list is complete - an item missing from it is gone - so the
* consumer never has to infer deletions from timestamps.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §4.
*/
class ManifestBuilder
{
public const CONTENT_PAGE_TYPE = 1730;
private const DOKTYPE_DEFAULT = 1;
// Recycler and backend user section: nothing below them is public.
private const DOKTYPES_NOT_TRAVERSED = [6, 255];
// l18n_cfg bit 1: "hide default translation of page".
private const L18N_HIDE_DEFAULT_LANGUAGE = 1;
private const RECORD_FIELDS = [
'product' => ['uid', 'tstamp', 'detail_page'],
'hotel' => ['uid', 'tstamp', 'detail_page'],
'region' => ['uid', 'tstamp', 'detail_page'],
'country' => ['uid', 'tstamp', 'detail_page'],
'city' => ['uid', 'tstamp', 'detail_page'],
'concept' => ['uid', 'tstamp', 'detail_page'],
'news' => ['uid', 'tstamp', 'pid'],
];
/**
* @var PageTree
*/
private $pageTree;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
PageTree $pageTree,
UrlResolver $urlResolver,
RecordFinder $recordFinder,
ContentFeedConfiguration $configuration,
ConnectionPool $connectionPool
) {
$this->pageTree = $pageTree;
$this->urlResolver = $urlResolver;
$this->recordFinder = $recordFinder;
$this->configuration = $configuration;
$this->connectionPool = $connectionPool;
}
public function build(): array
{
$sites = $this->urlResolver->getIncludedSites();
$records = $this->buildRecordItems();
return [
'generatedAt' => date('c'),
'sites' => array_map([$this, 'describeSite'], $sites),
'items' => array_merge($this->buildPageItems($sites, $this->collectDetailPages()), $records),
];
}
private function describeSite(Site $site): array
{
$languages = [];
foreach ($site->getLanguages() as $language) {
$languages[] = $language->getTwoLetterIsoCode();
}
$title = trim((string) ($site->getConfiguration()['websiteTitle'] ?? ''));
if ('' === $title) {
$title = trim($site->getDefaultLanguage()->getWebsiteTitle());
}
return [
'identifier' => $site->getIdentifier(),
'title' => '' !== $title ? $title : $site->getIdentifier(),
'base' => (string) $site->getBase(),
'languages' => $languages,
];
}
/**
* @param Site[] $sites
* @param array<int, array> $detailPages detailOf entries by page uid
*/
private function buildPageItems(array $sites, array $detailPages): array
{
$content = $this->findContentStatistics();
$excluded = array_flip($this->configuration->getExcludedPages());
$feedLanguages = $this->configuration->getLanguages();
$items = [];
foreach ($sites as $site) {
$languageIds = [];
foreach ($site->getLanguages() as $language) {
if (in_array($language->getLanguageId(), $feedLanguages, true)) {
$languageIds[$language->getLanguageId()] = $language->getTwoLetterIsoCode();
}
}
$context = [
'site' => $site,
'languages' => $languageIds,
'excluded' => $excluded,
'content' => $content,
'detailPages' => $detailPages,
];
$breadcrumbs = array_fill_keys(array_keys($languageIds), []);
$this->walk($site->getRootPageId(), $context, $breadcrumbs, true, $items);
}
return $items;
}
/**
* @param array<int, string[]> $breadcrumbs titles above this page, by language id
*/
private function walk(int $uid, array $context, array $breadcrumbs, bool $isRoot, array &$items): void
{
$page = $this->pageTree->getPage($uid);
if (null === $page || isset($context['excluded'][$uid])) {
return;
}
if (in_array((int) $page['doktype'], self::DOKTYPES_NOT_TRAVERSED, true)) {
return;
}
$visible = $this->pageTree->isVisible($page);
if (!$visible && (int) $page['extendToSubpages'] === 1) {
return;
}
$childBreadcrumbs = $breadcrumbs;
foreach ($context['languages'] as $languageId => $isoCode) {
$row = $this->getLanguageRow($page, $languageId);
if ($visible && null !== $row && $this->isPublishable($page, $row, $languageId)) {
$item = $this->buildPageItem($page, $row, $languageId, $isoCode, $breadcrumbs[$languageId], $context);
if (null !== $item) {
$items[] = $item;
}
}
if (!$isRoot) {
$source = $row ?? $page;
$childBreadcrumbs[$languageId][] = '' !== trim((string) $source['nav_title'])
? trim((string) $source['nav_title'])
: trim((string) $source['title']);
}
}
foreach ($this->pageTree->getChildren($uid) as $childUid) {
$this->walk($childUid, $context, $childBreadcrumbs, false, $items);
}
}
private function getLanguageRow(array $page, int $languageId): ?array
{
if (0 === $languageId) {
return $page;
}
$overlay = $this->pageTree->getOverlay((int) $page['uid'], $languageId);
return null !== $overlay && $this->pageTree->isVisible($overlay) ? $overlay : null;
}
/**
* Mirrors the XML sitemap: standard pages only, nothing marked no_index. If Google is not
* meant to see a page, the chatbot is not either.
*/
private function isPublishable(array $page, array $row, int $languageId): bool
{
if ((int) $page['doktype'] !== self::DOKTYPE_DEFAULT || (int) $row['no_index'] === 1) {
return false;
}
return 0 !== $languageId || 0 === ((int) $page['l18n_cfg'] & self::L18N_HIDE_DEFAULT_LANGUAGE);
}
private function buildPageItem(array $page, array $row, int $languageId, string $isoCode, array $breadcrumb, array $context): ?array
{
$uid = (int) $page['uid'];
// "Show content from page": the elements live on another page, and so do their changes.
// The site answers 404 when that page is hidden.
$contentPid = (int) $page['content_from_pid'] ?: $uid;
$contentPage = $this->pageTree->getPage($contentPid);
if (null === $contentPage || !$this->pageTree->isVisible($contentPage)) {
return null;
}
// Without a visible element other than plugins, the content-only render is empty (it
// leaves plugins out), so there is nothing to fetch. That is most product and hotel
// detail pages: their text comes through the records endpoint.
$content = $context['content'][$contentPid] ?? [];
$textElements = ($content[$languageId]['text'] ?? 0) + ($content[-1]['text'] ?? 0);
if (0 === $textElements) {
return null;
}
$url = $this->urlResolver->getPageUrl($uid, $languageId);
if (null === $url) {
return null;
}
$modified = max(
(int) $row['tstamp'],
$content[$languageId]['tstamp'] ?? 0,
$content[-1]['tstamp'] ?? 0
);
return [
'type' => 'page',
'uid' => $uid,
'language' => $isoCode,
'site' => $context['site']->getIdentifier(),
'url' => $url,
'contentUrl' => $this->urlResolver->getPageUrl($uid, $languageId, ['type' => self::CONTENT_PAGE_TYPE]),
'title' => trim((string) $row['title']),
'description' => $this->nullIfEmpty((string) $row['description']),
'breadcrumb' => $breadcrumb,
'detailOf' => $context['detailPages'][$uid] ?? [],
'modified' => date('c', $modified),
];
}
/**
* Per page and language: the latest change to any content element, and how many visible
* elements are not plugins.
* Deleted and hidden elements count towards the change - removing or hiding one changes the
* page - which is also why SYS_LASTCHANGED is no use here: it only moves on render.
*
* @return array<int, array<int, array{tstamp: int, text: int}>>
*/
private function findContentStatistics(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral(
'pid',
'sys_language_uid',
'MAX(tstamp) AS tstamp',
"SUM(CASE WHEN deleted = 0 AND hidden = 0 AND CType <> 'list' THEN 1 ELSE 0 END) AS text_elements"
)
->from('tt_content')
->groupBy('pid', 'sys_language_uid')
->execute()
->fetchAllAssociative()
;
$statistics = [];
foreach ($rows as $row) {
$statistics[(int) $row['pid']][(int) $row['sys_language_uid']] = [
'tstamp' => (int) $row['tstamp'],
'text' => (int) $row['text_elements'],
];
}
return $statistics;
}
/**
* Which records name a page as their detail page. Keyed by the page the record points at,
* not by where a shortcut leads: a shortcut is not in the manifest, and the page it leads to
* usually describes something broader (product detail shortcuts point at region pages).
*
* @return array<int, array>
*/
private function collectDetailPages(): array
{
$detailPages = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
if (!in_array('detail_page', $fields, true)) {
continue;
}
foreach ($this->recordFinder->find($type, ['uid', 'detail_page']) as $uid => $row) {
$pageUid = $this->urlResolver->parsePageReference((string) $row['detail_page']);
if (null !== $pageUid) {
$detailPages[$pageUid][] = ['type' => $type, 'uid' => $uid];
}
}
}
return $detailPages;
}
private function buildRecordItems(): array
{
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$items = [];
foreach (self::RECORD_FIELDS as $type => $fields) {
foreach ($this->recordFinder->find($type, $fields) as $uid => $row) {
if ('news' === $type) {
$link = $this->urlResolver->getNewsLink((int) $row['uid'], (int) $row['pid']);
// News has no detail_page of its own: an article stored outside the feed's
// sites (the retired ep-events) is not part of the feed at all.
if (null === $link) {
continue;
}
} else {
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
}
$modified = (int) $row['tstamp'];
if ('product' === $type) {
$modified = max($modified, $faqTimestamps[$uid] ?? 0);
}
if ('hotel' === $type) {
$modified = max($modified, $matchcodeTimestamps[$uid] ?? 0);
}
$items[] = [
'type' => $type,
'uid' => $uid,
'language' => 'de',
'site' => $link['site'],
'url' => $link['url'],
'modified' => date('c', $modified),
];
}
}
return $items;
}
private function nullIfEmpty(string $value): ?string
{
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,131 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
/**
* The whole page table in memory, for the content feed's tree walk and shortcut resolution.
*
* About 2 900 rows: one query and a PHP walk is far cheaper than a query per tree level.
* Visibility is evaluated here, not in SQL, because a hidden page still has to be traversed
* (its subpages stay reachable unless extendToSubpages is set).
*/
class PageTree
{
private const FIELDS = [
'uid', 'pid', 'sorting', 'doktype', 'hidden', 'starttime', 'endtime', 'fe_group',
'extendToSubpages', 'no_index', 'title', 'nav_title', 'description', 'shortcut',
'shortcut_mode', 'content_from_pid', 'tstamp', 'sys_language_uid', 'l10n_parent', 'l18n_cfg',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
/**
* @var int
*/
private $now;
/**
* @var array<int, array>|null default-language rows by uid
*/
private $pages;
/**
* @var array<int, int[]> child uids by parent uid, in sorting order
*/
private $children = [];
/**
* @var array<int, array<int, array>> overlay rows by default-language uid and language id
*/
private $overlays = [];
public function __construct(ConnectionPool $connectionPool, Context $context)
{
$this->connectionPool = $connectionPool;
$this->now = (int) $context->getPropertyFromAspect('date', 'timestamp');
}
public function getPage(int $uid): ?array
{
$this->load();
return $this->pages[$uid] ?? null;
}
public function getOverlay(int $uid, int $languageId): ?array
{
$this->load();
return $this->overlays[$uid][$languageId] ?? null;
}
/**
* @return int[]
*/
public function getChildren(int $uid): array
{
$this->load();
return $this->children[$uid] ?? [];
}
/**
* Visible to an anonymous frontend visitor: not hidden, inside its start/end window, and not
* restricted to a frontend user group.
*/
public function isVisible(array $row): bool
{
if ((int) $row['hidden'] === 1) {
return false;
}
if ((int) $row['starttime'] > $this->now) {
return false;
}
if ((int) $row['endtime'] !== 0 && (int) $row['endtime'] <= $this->now) {
return false;
}
return in_array((string) $row['fe_group'], ['', '0'], true);
}
private function load(): void
{
if (null !== $this->pages) {
return;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction());
$rows = $queryBuilder
->select(...self::FIELDS)
->from('pages')
->orderBy('sorting')
->execute()
->fetchAllAssociative()
;
$this->pages = [];
foreach ($rows as $row) {
$uid = (int) $row['uid'];
if ((int) $row['sys_language_uid'] > 0) {
$this->overlays[(int) $row['l10n_parent']][(int) $row['sys_language_uid']] = $row;
continue;
}
$this->pages[$uid] = $row;
$this->children[(int) $row['pid']][] = $uid;
}
}
}
@@ -0,0 +1,152 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Which records the content feed publishes. The manifest and the records endpoint both go
* through here, so a uid the manifest lists is exactly a uid the records endpoint returns.
*
* The default restrictions apply: deleted, hidden, starttime and endtime.
*/
class RecordFinder
{
public const TYPES = [
'product' => 'tx_epproducts_domain_model_product',
'hotel' => 'tx_epproducts_domain_model_hotel',
'region' => 'tx_epproducts_domain_model_region',
'country' => 'tx_epproducts_domain_model_country',
'city' => 'tx_epproducts_domain_model_city',
'concept' => 'tx_epproducts_domain_model_concept',
'news' => 'tx_news_domain_model_news',
];
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(ConnectionPool $connectionPool)
{
$this->connectionPool = $connectionPool;
}
/**
* @param string[] $fields
* @param int[]|null $uids null for all eligible records
* @return array<int, array> rows by uid, in uid order
*/
public function find(string $type, array $fields, ?array $uids = null): array
{
$table = self::TYPES[$type];
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$queryBuilder
->select(...$fields)
->from($table)
->where($queryBuilder->expr()->in('sys_language_uid', [0, -1]))
->orderBy('uid')
;
if (null !== $uids) {
if ([] === $uids) {
return [];
}
$queryBuilder->andWhere($queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)
));
}
$this->addTypeConstraints($type, $queryBuilder);
$rows = [];
foreach ($queryBuilder->execute()->fetchAllAssociative() as $row) {
$rows[(int) $row['uid']] = $row;
}
return $rows;
}
/**
* Latest change to a product's FAQs, by product uid. Deleted and hidden FAQs count: removing
* one is a change to the product's text.
*
* @return array<int, int>
*/
public function findFaqTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('mm.uid_local AS product', 'MAX(faq.tstamp) AS tstamp')
->from('tx_epproducts_product_faq_mm', 'mm')
->join('mm', 'tx_epproducts_domain_model_faq', 'faq', 'faq.uid = mm.uid_foreign')
->groupBy('mm.uid_local')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'product');
}
/**
* Latest change to a hotel's matchcode aliases, by hotel uid. They are part of the hotel's
* `codes`, which the consumer joins its trip data on.
*
* @return array<int, int>
*/
public function findMatchcodeTimestamps(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$queryBuilder->getRestrictions()->removeAll();
$rows = $queryBuilder
->selectLiteral('entity AS hotel', 'MAX(tstamp) AS tstamp')
->from('tx_epproducts_domain_model_matchcode')
->where($queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')))
->groupBy('entity')
->execute()
->fetchAllAssociative()
;
return $this->indexTimestamps($rows, 'hotel');
}
/**
* @return array<int, int>
*/
private function indexTimestamps(array $rows, string $key): array
{
$timestamps = [];
foreach ($rows as $row) {
$timestamps[(int) $row[$key]] = (int) $row['tstamp'];
}
return $timestamps;
}
private function addTypeConstraints(string $type, QueryBuilder $queryBuilder): void
{
$expr = $queryBuilder->expr();
if ('product' === $type) {
// A product that is neither searchable nor bookable is an internal record.
$queryBuilder->andWhere($expr->orX($expr->eq('searchable', 1), $expr->eq('bookable', 1)));
}
if ('news' === $type) {
// Type 0 is a regular article; 1 and 2 are just links to a page or an external URL.
$queryBuilder->andWhere(
$expr->eq('type', $queryBuilder->createNamedParameter('0')),
$expr->in('fe_group', $queryBuilder->createNamedParameter(['', '0'], Connection::PARAM_STR_ARRAY))
);
}
}
}
@@ -0,0 +1,513 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Builds GET /api/content/records: the editorial content of products, hotels, destinations,
* concepts and blog articles. Filter attributes and text only - no dates, prices or images.
* Dates and prices are the consumer's own data; images play no part in a text chatbot.
*
* Rich-text fields go out as stored HTML with `t3://` links resolved; the consumer converts
* them to Markdown together with the rendered pages, so there is one HTML-to-text step.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §5.
*/
class RecordSerializer
{
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RteLinkResolver
*/
private $rteLinkResolver;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
RecordFinder $recordFinder,
UrlResolver $urlResolver,
RteLinkResolver $rteLinkResolver,
ConnectionPool $connectionPool
) {
$this->recordFinder = $recordFinder;
$this->urlResolver = $urlResolver;
$this->rteLinkResolver = $rteLinkResolver;
$this->connectionPool = $connectionPool;
}
/**
* @param int[] $uids
* @return array[] one item per eligible uid; ineligible uids are left out
*/
public function serialize(string $type, array $uids): array
{
$rows = $this->recordFinder->find($type, ['*'], $uids);
if ([] === $rows) {
return [];
}
switch ($type) {
case 'product':
return $this->serializeProducts($rows);
case 'hotel':
return $this->serializeHotels($rows);
case 'news':
return $this->serializeNews($rows);
default:
return array_values(array_map(function (array $row) use ($type): array {
return $this->serializeDestination($type, $row);
}, $rows));
}
}
private function serializeProducts(array $rows): array
{
$uids = array_keys($rows);
$hotels = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_local', 'uid_foreign', $uids);
$regions = $this->findMmUids('tx_epproducts_product_region_mm', 'uid_local', 'uid_foreign', $uids);
$faqs = $this->findFaqs($uids);
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$infoboxes = $this->findInfoboxTexts(array_column($rows, 'non_bookable_info'));
$items = [];
foreach ($rows as $uid => $row) {
$season = trim((string) $row['season']);
$items[] = $this->withLink($row, max((int) $row['tstamp'], $faqTimestamps[$uid] ?? 0)) + [
'code' => $this->text($row['code']),
'busProId' => (int) $row['bus_pro_id'] ?: null,
'name' => $this->text($row['name']),
'subline' => $this->text($row['subline']),
'headline' => $this->text($row['headline']),
'season' => '' === $season ? 'w' : $season,
'daytrip' => (bool) $row['daytrip'],
'bookable' => (bool) $row['bookable'],
'searchable' => (bool) $row['searchable'],
'skipassIncluded' => (bool) $row['skipass_included'],
'conceptUid' => (int) $row['concept'] ?: null,
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'additionalRegionUids' => $regions[$uid] ?? [],
'hotelUids' => $hotels[$uid] ?? [],
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'teaserLong' => $this->html($row['teaser_long']),
'feature' => $this->text($row['feature']),
'conceptDescription' => $this->html($row['concept_description']),
'boardDescription' => $this->html($row['board_description']),
'includedServices' => $this->html($row['included_services']),
'additionalServices' => $this->html($row['additional_services']),
'programmeDescription' => $this->html($row['programme_description']),
'skiPassDescription' => $this->html($row['ski_pass_description']),
'nonBookableInfo' => $infoboxes[(int) $row['non_bookable_info']] ?? null,
],
'faqs' => $faqs[$uid] ?? [],
];
}
return $items;
}
private function serializeHotels(array $rows): array
{
$uids = array_keys($rows);
$aliases = $this->findMatchcodes($uids);
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$products = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_foreign', 'uid_local', $uids, 'sorting_foreign');
$eligibleProducts = $this->recordFinder->find('product', ['uid'], array_values(array_unique(array_merge([], ...array_values($products)))));
$items = [];
foreach ($rows as $uid => $row) {
$code = trim((string) $row['code']);
$codes = array_values(array_unique(array_filter(array_merge([$code], $aliases[$uid] ?? []), 'strlen')));
$items[] = $this->withLink($row, max((int) $row['tstamp'], $matchcodeTimestamps[$uid] ?? 0)) + [
'code' => '' === $code ? null : $code,
'codes' => $codes,
'name' => $this->text($row['name']),
'shortName' => $this->text($row['short_name']),
'headline' => $this->text($row['headline']),
'type' => (int) $row['type'],
'category' => (int) $row['category'],
'new' => (bool) $row['new'],
'earlybird' => (bool) $row['earlybird'],
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'productUids' => array_values(array_filter($products[$uid] ?? [], function (int $productUid) use ($eligibleProducts): bool {
return isset($eligibleProducts[$productUid]);
})),
'address' => $this->text($row['address']),
'latitude' => $this->text($row['latitude']),
'longitude' => $this->text($row['longitude']),
'facts' => [
'bedsCount' => (int) $row['beds_count'] ?: null,
'parkingCount' => (int) $row['parking_count'] ?: null,
'altitude' => $this->text($row['altitude']),
'distanceBus' => $this->text($row['distance_bus']),
'distanceLift' => $this->text($row['distance_lift']),
'shower' => $this->text($row['shower']),
'partyroom' => (bool) $row['partyroom'],
'gamesroom' => (bool) $row['gamesroom'],
'sauna' => (bool) $row['sauna'],
'wifi' => (bool) $row['wifi'],
],
'icons' => HotelIconUtility::getIcons($row),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'features' => $this->html($row['features']),
'roomTypes' => $this->html($row['room_types']),
'additionalInformation' => $this->html($row['additional_information']),
],
];
}
return $items;
}
private function serializeDestination(string $type, array $row): array
{
$item = $this->withLink($row, (int) $row['tstamp']) + [
'name' => $this->text($row['name']),
'headline' => $this->text($row['headline']),
];
switch ($type) {
case 'region':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'facts' => [
'length' => $this->text($row['length']),
'height' => $this->text($row['height']),
'lifts' => $this->text($row['lifts']),
],
'webcam' => $this->findWebcamUrl((int) $row['webcam']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'feature' => $this->text($row['feature']),
'skiArea' => $this->html($row['ski_area']),
'skiAreaExtended' => $this->html($row['ski_area_extended']),
'skiPass' => $this->html($row['ski_pass']),
'news' => $this->html($row['news']),
],
];
case 'country':
return $item + [
'code' => $this->text($row['code']),
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
],
];
case 'city':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'party' => $this->html($row['party']),
'leisure' => $this->html($row['leisure']),
],
];
default: // concept
return $item + [
'code' => $this->text($row['code']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'popupText' => $this->html($row['popup_text']),
],
];
}
}
private function serializeNews(array $rows): array
{
$uids = array_keys($rows);
$tags = $this->findNewsTags($uids);
$categories = $this->findNewsCategories($uids);
$items = [];
foreach ($rows as $uid => $row) {
$link = $this->urlResolver->getNewsLink($uid, (int) $row['pid']);
if (null === $link) {
continue;
}
$items[] = [
'uid' => $uid,
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', (int) $row['tstamp']),
'title' => $this->text($row['title']),
'teaser' => $this->html($row['teaser']),
'bodytext' => $this->html($row['bodytext']),
'datetime' => (int) $row['datetime'] > 0 ? date('c', (int) $row['datetime']) : null,
'tags' => $tags[$uid] ?? [],
'categories' => $categories[$uid] ?? [],
];
}
return $items;
}
/**
* The identity and link fields every record carries, with the same values as the manifest.
*/
private function withLink(array $row, int $modified): array
{
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
return [
'uid' => (int) $row['uid'],
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', $modified),
];
}
/**
* @param int[] $uids
* @return array<int, int[]> related uids by $keyField, in $sortField order
*/
private function findMmUids(string $table, string $keyField, string $valueField, array $uids, string $sortField = 'sorting'): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$rows = $queryBuilder
->select($keyField, $valueField)
->from($table)
->where($queryBuilder->expr()->in($keyField, $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)))
->orderBy($keyField)
->addOrderBy($sortField)
->execute()
->fetchAllAssociative()
;
$related = [];
foreach ($rows as $row) {
$related[(int) $row[$keyField]][] = (int) $row[$valueField];
}
return $related;
}
/**
* @param int[] $productUids
* @return array<int, array[]> FAQs by product uid, in the order the editor arranged them
*/
private function findFaqs(array $productUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$rows = $queryBuilder
->select('mm.uid_local AS product', 'faq.question', 'faq.answer', 'faq.category')
->from('tx_epproducts_domain_model_faq', 'faq')
->join('faq', 'tx_epproducts_product_faq_mm', 'mm', 'mm.uid_foreign = faq.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.uid_local')
->addOrderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$faqs = [];
foreach ($rows as $row) {
$faqs[(int) $row['product']][] = [
'question' => $this->text($row['question']),
'answer' => $this->html($row['answer']),
'category' => $this->text($row['category']),
];
}
return $faqs;
}
/**
* Matchcode aliases by hotel uid. With the hotel's own code they are every code BusPro may use
* for it - the same set DateImportService::getHotelMappings() resolves.
*
* @param int[] $hotelUids
* @return array<int, string[]>
*/
private function findMatchcodes(array $hotelUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$rows = $queryBuilder
->select('entity', 'code')
->from('tx_epproducts_domain_model_matchcode')
->where(
$queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')),
$queryBuilder->expr()->in('entity', $queryBuilder->createNamedParameter($hotelUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('code')
->execute()
->fetchAllAssociative()
;
$codes = [];
foreach ($rows as $row) {
$codes[(int) $row['entity']][] = trim((string) $row['code']);
}
return $codes;
}
/**
* @param array $infoboxUids
* @return array<int, string|null>
*/
private function findInfoboxTexts(array $infoboxUids): array
{
$infoboxUids = array_values(array_filter(array_map('intval', $infoboxUids)));
if ([] === $infoboxUids) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_infobox');
$rows = $queryBuilder
->select('uid', 'text')
->from('tx_epproducts_domain_model_infobox')
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter(array_unique($infoboxUids), Connection::PARAM_INT_ARRAY)))
->execute()
->fetchAllAssociative()
;
$texts = [];
foreach ($rows as $row) {
$texts[(int) $row['uid']] = $this->html($row['text']);
}
return $texts;
}
private function findWebcamUrl(int $webcamUid): ?string
{
if (0 === $webcamUid) {
return null;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_webcam');
$url = $queryBuilder
->select('url')
->from('tx_epproducts_domain_model_webcam')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($webcamUid, \PDO::PARAM_INT)))
->execute()
->fetchOne()
;
return false === $url ? null : $this->text($url);
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsTags(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_tag');
$rows = $queryBuilder
->select('mm.uid_local AS news', 'tag.title')
->from('tx_news_domain_model_tag', 'tag')
->join('tag', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = tag.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$tags = [];
foreach ($rows as $row) {
$tags[(int) $row['news']][] = trim((string) $row['title']);
}
return $tags;
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsCategories(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('mm.uid_foreign AS news', 'category.title')
->from('sys_category', 'category')
->join('category', 'sys_category_record_mm', 'mm', 'mm.uid_local = category.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news')),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories')),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('mm.sorting_foreign')
->execute()
->fetchAllAssociative()
;
$categories = [];
foreach ($rows as $row) {
$categories[(int) $row['news']][] = trim((string) $row['title']);
}
return $categories;
}
/**
* Rich text as stored, with internal links made absolute.
*/
private function html($value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $this->rteLinkResolver->resolve($value);
}
/**
* Plain text with whitespace collapsed. Product names carry literal line breaks the website
* splits on for layout ("Apartments \nVal Thorens"); the chatbot needs them as one line.
*/
private function text($value): ?string
{
$value = trim((string) preg_replace('/\s+/u', ' ', (string) $value));
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,104 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
/**
* Rewrites `t3://` links in stored RTE HTML to absolute URLs.
*
* parseFunc_RTE would do this, but it needs a TSFE and the API middleware runs before one exists.
* Only links are touched; the rest of the HTML is passed on as stored. A link that cannot be
* resolved to something public (a page outside the feed's sites, a deleted page) loses its href
* and keeps its text.
*/
class RteLinkResolver
{
/**
* @var LinkService
*/
private $linkService;
/**
* @var UrlResolver
*/
private $urlResolver;
public function __construct(LinkService $linkService, UrlResolver $urlResolver)
{
$this->linkService = $linkService;
$this->urlResolver = $urlResolver;
}
public function resolve(string $html): string
{
if (false === strpos($html, 't3://')) {
return $html;
}
return (string) preg_replace_callback(
'/\shref=(["\'])(t3:\/\/[^"\']*)\1/i',
function (array $matches): string {
$url = $this->resolveLink(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5));
return null === $url ? '' : ' href="' . htmlspecialchars($url) . '"';
},
$html
);
}
private function resolveLink(string $link): ?string
{
try {
$data = $this->linkService->resolve($link);
} catch (\Throwable $e) {
return null;
}
if (LinkService::TYPE_PAGE === ($data['type'] ?? null)) {
$pageUid = $this->urlResolver->parsePageReference('t3://page?uid=' . ($data['pageuid'] ?? ''));
$target = null === $pageUid ? null : $this->urlResolver->resolveLinkTarget($pageUid);
$url = null === $target ? null : $this->urlResolver->getPageUrl($target);
if (null !== $url && '' !== (string) ($data['fragment'] ?? '')) {
$url .= '#' . $data['fragment'];
}
return $url;
}
if (LinkService::TYPE_FILE === ($data['type'] ?? null) && ($data['file'] ?? null) instanceof File) {
return $this->absolute((string) $data['file']->getPublicUrl());
}
if (LinkService::TYPE_URL === ($data['type'] ?? null)) {
return (string) $data['url'];
}
return null;
}
/**
* File URLs come back relative to the document root; the consumer has no page to resolve
* them against for record text, so make them absolute against the main site.
*/
private function absolute(string $url): ?string
{
if ('' === $url) {
return null;
}
if (1 === preg_match('/^https?:\/\//', $url)) {
return $url;
}
$sites = $this->urlResolver->getIncludedSites();
if ([] === $sites) {
return null;
}
return rtrim((string) $sites[0]->getBase(), '/') . '/' . ltrim($url, '/');
}
}
@@ -0,0 +1,214 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Absolute public URLs for the content feed, generated through each site's router so that
* baseVariants apply (staging and DDEV get their own hosts) and route enhancers shape the path.
*/
class UrlResolver
{
private const DOKTYPE_DEFAULT = 1;
private const DOKTYPE_SHORTCUT = 4;
private const SHORTCUT_MODE_NONE = 0;
private const SHORTCUT_MODE_FIRST_SUBPAGE = 1;
private const SHORTCUT_MODE_PARENT_PAGE = 3;
private const MAX_SHORTCUT_DEPTH = 5;
/**
* @var SiteFinder
*/
private $siteFinder;
/**
* @var PageTree
*/
private $pageTree;
/**
* @var string[]
*/
private $includedSites;
/**
* @var array<string, int> news detail page uid by site identifier
*/
private $newsDetailPages;
/**
* @var array<int, Site|null>
*/
private $sitesByPage = [];
public function __construct(SiteFinder $siteFinder, PageTree $pageTree, ContentFeedConfiguration $configuration)
{
$this->siteFinder = $siteFinder;
$this->pageTree = $pageTree;
$this->includedSites = $configuration->getSites();
$this->newsDetailPages = $configuration->getNewsDetailPages();
}
/**
* @return Site[] included sites, in configuration order
*/
public function getIncludedSites(): array
{
$sites = [];
foreach ($this->includedSites as $identifier) {
try {
$sites[] = $this->siteFinder->getSiteByIdentifier($identifier);
} catch (SiteNotFoundException $e) {
// A misspelt identifier must not take the whole feed down; it just contributes nothing.
}
}
return $sites;
}
/**
* The page's site, or null when it belongs to no site or to one outside the feed.
*/
public function getIncludedSite(int $pageUid): ?Site
{
if (!array_key_exists($pageUid, $this->sitesByPage)) {
try {
$site = $this->siteFinder->getSiteByPageId($pageUid);
$this->sitesByPage[$pageUid] = in_array($site->getIdentifier(), $this->includedSites, true) ? $site : null;
} catch (SiteNotFoundException $e) {
$this->sitesByPage[$pageUid] = null;
}
}
return $this->sitesByPage[$pageUid];
}
public function getPageUrl(int $pageUid, int $languageId = 0, array $parameters = []): ?string
{
$site = $this->getIncludedSite($pageUid);
if (null === $site) {
return null;
}
try {
$language = $site->getLanguageById($languageId);
return (string) $site->getRouter()->generateUri($pageUid, ['_language' => $language] + $parameters);
} catch (\Throwable $e) {
// Unroutable pages (missing slug, disabled language) are left out rather than failing the feed.
return null;
}
}
/**
* Parses a typolink page reference as stored in `detail_page`: a plain uid or `t3://page?uid=N`.
*/
public function parsePageReference(string $reference): ?int
{
$reference = trim($reference);
if (MathUtility::canBeInterpretedAsInteger($reference)) {
return (int) $reference > 0 ? (int) $reference : null;
}
if (1 === preg_match('/^t3:\/\/page\?(?:.*&)?uid=(\d+)/', html_entity_decode($reference), $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Follows shortcut pages the way a typolink does, and returns the uid of the visible standard
* page a link to $pageUid ends up on - or null when the chain breaks or loops.
*/
public function resolveLinkTarget(int $pageUid): ?int
{
for ($depth = 0; $depth < self::MAX_SHORTCUT_DEPTH; $depth++) {
$page = $this->pageTree->getPage($pageUid);
if (null === $page || !$this->pageTree->isVisible($page)) {
return null;
}
if ((int) $page['doktype'] !== self::DOKTYPE_SHORTCUT) {
return (int) $page['doktype'] === self::DOKTYPE_DEFAULT ? $pageUid : null;
}
$next = $this->getShortcutTarget($page);
if (null === $next) {
return null;
}
$pageUid = $next;
}
return null;
}
/**
* @return array{url: ?string, site: ?string}
*/
public function getRecordLink(string $detailPage): array
{
$pageUid = $this->parsePageReference($detailPage);
$target = null === $pageUid ? null : $this->resolveLinkTarget($pageUid);
$site = null === $target ? null : $this->getIncludedSite($target);
$url = null === $site ? null : $this->getPageUrl($target);
return [
'url' => $url,
'site' => null === $url ? null : $site->getIdentifier(),
];
}
/**
* News has no detail_page of its own: the storage folder decides the site, and the site's
* configured detail page carries the article through the NewsDetailPlugin route enhancer.
*
* @return array{url: ?string, site: string}|null null when the article is stored outside the feed's sites
*/
public function getNewsLink(int $newsUid, int $storagePid): ?array
{
$site = $this->getIncludedSite($storagePid);
if (null === $site) {
return null;
}
$detailPage = $this->newsDetailPages[$site->getIdentifier()] ?? null;
$url = null === $detailPage ? null : $this->getPageUrl($detailPage, 0, [
'tx_news_pi1' => ['action' => 'detail', 'controller' => 'News', 'news' => $newsUid],
]);
return ['url' => $url, 'site' => $site->getIdentifier()];
}
private function getShortcutTarget(array $page): ?int
{
switch ((int) $page['shortcut_mode']) {
case self::SHORTCUT_MODE_NONE:
return (int) $page['shortcut'] > 0 ? (int) $page['shortcut'] : null;
case self::SHORTCUT_MODE_FIRST_SUBPAGE:
foreach ($this->pageTree->getChildren((int) $page['uid']) as $childUid) {
if ($this->pageTree->isVisible($this->pageTree->getPage($childUid))) {
return $childUid;
}
}
return null;
case self::SHORTCUT_MODE_PARENT_PAGE:
return (int) $page['pid'] > 0 ? (int) $page['pid'] : null;
default:
// Random subpage: there is no stable URL to give.
return null;
}
}
}
@@ -27,36 +27,173 @@ namespace EP\EpProducts\Controller;
* This copyright notice MUST APPEAR in all copies of the script! * This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/ ***************************************************************/
use EP\EpProducts\BpnConnect\ApiClient; use EP\EpProducts\BpnConnect\ApiClient as BpnConnectClient;
use EP\EpProducts\MyEP\ApiClient as MyEpClient;
use EP\EpProducts\Domain\Model\Hotel; use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\MyEP\ApiException;
use EP\EpProducts\Utility\DateUtility; use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
class AjaxCalendarController extends ActionController class AjaxCalendarController extends ActionController
{ {
private const CACHE_TTL = 3600; private const CACHE_TTL = 3600;
/** protected BpnConnectClient $bpnConnectClient;
* @var ApiClient protected MyEpClient $myEpClient;
*/ protected FrontendInterface $cache;
protected $apiClient;
/** public function __construct(BpnConnectClient $bpnConnectClient, MyEpClient $myEpClient, FrontendInterface $cache)
* @var FrontendInterface
*/
protected $cache;
/**
* @param ApiClient $apiClient
* @param FrontendInterface $cache
*/
public function __construct(ApiClient $apiClient, FrontendInterface $cache)
{ {
$this->apiClient = $apiClient; $this->bpnConnectClient = $bpnConnectClient;
$this->myEpClient = $myEpClient;
$this->cache = $cache; $this->cache = $cache;
} }
public function priceCalendarAction(Hotel $hotel, int $months = 12): void
{
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$dateRange = DateUtility::getDateRange(null, null, $months);
$period = $dateRange['period'];
$today = new \DateTimeImmutable('today');
$priceDataByDate = [];
$error = false;
$errorMessage = '';
if ($hotelCode !== '') {
try {
// This action is cacheable, so the rendered markup is held in the page cache
// for config.cache_period. An additional application-level cache here would
// key on exactly the same granularity (hotel + date range) and only stack a
// second TTL on top, pushing worst-case staleness past the freshness budget.
$rawData = $this->myEpClient->getPriceConfigData($hotelCode);
$availableDates = [];
foreach ($rawData as $item) {
if (($item['status'] ?? '') === 'OK') {
$availableDates[$item['date']] = true;
}
}
// The API no longer honours a date-range constraint, so it may return data
// further out than the default $months grid covers. Grow the grid to match
// whatever the API actually returned rather than silently dropping the tail.
$lastDataDate = null;
foreach ($rawData as $item) {
$date = $item['date'];
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($dt < $today) {
continue;
}
if (null === $lastDataDate || $dt > $lastDataDate) {
$lastDataDate = $dt;
}
$prevDate = $dt->modify('-1 day')->format('Y-m-d');
$isAvailable = ($item['status'] ?? '') === 'OK';
$prevAvailable = isset($availableDates[$prevDate]);
if ($isAvailable) {
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
} elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
} else {
$dayStatus = 'blocked';
}
$priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
}
if (null !== $lastDataDate) {
$dataEnd = $lastDataDate->modify('first day of next month')->setTime(0, 0, 0);
if ($dataEnd > $period->getEndDate()) {
$period = new \League\Period\Period($period->getStartDate(), $dataEnd);
}
}
} catch (ApiException $e) {
$error = true;
$errorMessage = $e->getMessage();
$this->disablePageCache();
}
} else {
$error = true;
$errorMessage = 'Missing hotel code';
$this->disablePageCache();
}
$this->view->assignMultiple([
'priceDataByDate' => $priceDataByDate,
'calendarMonths' => $this->buildCalendarMonths($period),
'error' => $error,
'errorMessage' => $errorMessage,
]);
}
public function priceConfigAction(Hotel $hotel): void
{
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceConfig = [
'data' => [],
'success' => true,
'error' => null,
];
try {
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode);
} catch (ApiException $e) {
$priceConfig['success'] = false;
$priceConfig['error'] = $e->getMessage();
$this->disablePageCache();
}
$this->view->assign('priceConfig', $priceConfig);
}
public function priceTableAction(Hotel $hotel, string $year = null): void
{
$year = $year ?? (new \DateTimeImmutable())->format('Y');
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceTable = [
'data' => [],
'success' => true,
'error' => null,
];
try {
$priceTable['data'] = $this->myEpClient->getPriceTableData($hotelCode, $year);
} catch (ApiException $e) {
$priceTable['success'] = false;
$priceTable['error'] = $e->getMessage();
$this->disablePageCache();
}
$this->view->assignMultiple([
'hotel' => $hotel,
'year' => $year,
'priceTable' => $priceTable,
]);
}
/** /**
* @param Hotel $hotel * @param Hotel $hotel
* @param string $year * @param string $year
@@ -84,18 +221,18 @@ class AjaxCalendarController extends ActionController
$hotelCode, $hotelCode,
$apiDateFrom, $apiDateFrom,
$apiDateTo, $apiDateTo,
ApiClient::CALENDAR_MODE_DAYS BpnConnectClient::CALENDAR_MODE_DAYS
); );
$cachedCalendar = $this->cache->get($cacheIdentifier); $cachedCalendar = $this->cache->get($cacheIdentifier);
if (false !== $cachedCalendar) { if (false !== $cachedCalendar) {
$calendar = $cachedCalendar; $calendar = $cachedCalendar;
} else { } else {
$calendar = $this->apiClient->getCalendar( $calendar = $this->bpnConnectClient->getCalendar(
$hotelCode, $hotelCode,
new \DateTimeImmutable($apiDateFrom), new \DateTimeImmutable($apiDateFrom),
new \DateTimeImmutable($apiDateTo), new \DateTimeImmutable($apiDateTo),
ApiClient::CALENDAR_MODE_DAYS BpnConnectClient::CALENDAR_MODE_DAYS
); );
$this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL); $this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL);
} }
@@ -112,6 +249,83 @@ class AjaxCalendarController extends ActionController
]); ]);
} }
/**
* Tag this page-cache entry with the hotel it renders, so the entry can be flushed for
* that hotel alone rather than by clearing the whole page cache and re-warming every
* calendar. Tags are stored alongside the entry by
* TypoScriptFrontendController::setPageCacheContent().
*
* Used manually today, when one hotel's availability needs re-fetching before its hour
* is up:
*
* typo3cms cache:flushtags ep_hotel_<code> --groups pages
*/
private function tagPageCacheForHotel(string $hotelCode): void
{
if ('' === $hotelCode) {
return;
}
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->addCacheTags(['ep_hotel_' . $hotelCode]);
}
}
/**
* The calendar actions are cacheable, so an API failure would otherwise pin the error
* markup in the page cache for the full cache_period. Drop caching for this request so
* the next visitor gets a fresh attempt instead of an hour-old error message.
*
* The second argument must be true: set_no_cache() ignores the call otherwise, because
* $TYPO3_CONF_VARS['FE']['disableNoCacheParameter'] defaults to true.
*/
private function disablePageCache(): void
{
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->set_no_cache('Price calendar data could not be retrieved', true);
}
}
private function buildTooltip(string $status, array $item): string
{
if ($status === 'blocked') {
return 'Nicht verfügbar';
}
if ($status === 'checkout-only') {
return 'Nur Abreise';
}
return $this->buildPriceTooltip($item);
}
private function buildPriceTooltip(array $item): string
{
$price = $item['pricePerNight'] ?? null;
$currency = $this->getCurrencySymbol($item['currency'] ?? null);
if ($price !== null) {
$priceStr = (($item['type'] ?? null) === 'discount')
? $price . ' ' . $currency
: 'ab ' . $price . ' ' . $currency;
} else {
$priceStr = 'Anfrage';
}
$minNights = (int)($item['minNights'] ?? 0);
return $minNights > 1
? $priceStr . ' · min. ' . $minNights . ' Nächte'
: $priceStr;
}
private function getCurrencySymbol(?string $currency): string
{
if (null === $currency || '' === $currency) {
return '€';
}
return 'EUR' === $currency ? '€' : $currency;
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -1,170 +0,0 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2020 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Dto\GroupsPriceInquiry;
use EP\EpProducts\Domain\Model\GroupsPriceBoard;
use EP\EpProducts\Domain\Model\GroupsPriceOption;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\Domain\Repository\GroupsPriceOptionRepository;
use EP\EpProducts\Service\EmailService;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
class AjaxGroupsPriceController extends ActionController
{
/**
* @var GroupsPriceOptionRepository
*/
protected $groupsPriceOptionRepository;
/**
* @var EmailService
*/
protected $emailService;
public function __construct(GroupsPriceOptionRepository $groupsPriceOptionRepository, EmailService $emailService)
{
$this->groupsPriceOptionRepository = $groupsPriceOptionRepository;
$this->emailService = $emailService;
}
public function initializeProcessFormAction()
{
if ($this->request->hasArgument('groupsPriceInquiry')) {
$this->arguments->getArgument('groupsPriceInquiry')
->getPropertyMappingConfiguration()->allowAllProperties();
}
}
/**
* @param Hotel $hotel
* @param GroupsPriceInquiry $groupsPriceInquiry
* @return string
*/
public function processFormAction(Hotel $hotel, GroupsPriceInquiry $groupsPriceInquiry)
{
$mode = $groupsPriceInquiry->getMode();
$country = $hotel->getCountry()->getCode();
$effectivePax = $groupsPriceInquiry->getEffectivePax();
$nights = $groupsPriceInquiry->getNights();
if (false === in_array($mode, [GroupsPriceInquiry::MODE_BOOKING, GroupsPriceInquiry::MODE_INQUIRY])) {
$mode = GroupsPriceInquiry::MODE_BOOKING;
}
$subject = GroupsPriceInquiry::MODE_BOOKING ?
'Gruppenhaus Preisberechnung/Buchung' : 'Gruppenhaus Preisberechnung/Anfrage';
$emailOptions = [
'toEmail' => $this->settings['groupsPriceToEmail'],
'toName' => $this->settings['groupsPriceToName'],
'fromEmail' => $this->settings['groupsPriceToEmail'],
'fromName' => $this->settings['groupsPriceToName'],
'subject' => $subject,
'templateName' => 'GroupsPrice',
];
$optionPrice = function (GroupsPriceOption $option) use ($country, $effectivePax, $nights) {
$price = 'CH' === $country ? $option->getPriceChf() : $option->getPrice();
$currency = 'CH' === $country ? 'CHF' : '€';
if (GroupsPriceOption::TYPE_PER_PAX === $option->getType()) {
$price = $price * $effectivePax;
} elseif (GroupsPriceOption::TYPE_PER_NIGHT === $option->getType()) {
$price = $price * $nights;
} elseif (GroupsPriceOption::TYPE_PER_PAX_AND_NIGHT === $option->getType()) {
$price = $price * $nights * $effectivePax;
}
return $price.' '.$currency;
};
try {
$options = $this->groupsPriceOptionRepository->findByUids($groupsPriceInquiry->getOptions());
$selectedOptions = array_map(function ($option) use ($optionPrice) {
return $option->getTitle().': '.$optionPrice($option);
}, $options);
}
catch (InvalidQueryException $e) {
$selectedOptions = [];
}
$variables = [
'mode' => $mode,
'hotel' => $hotel,
'name' => $groupsPriceInquiry->getName(),
'group' => $groupsPriceInquiry->getGroup(),
'street' => $groupsPriceInquiry->getStreet(),
'postcode' => $groupsPriceInquiry->getPostcode(),
'city' => $groupsPriceInquiry->getCity(),
'email' => $groupsPriceInquiry->getEmail(),
'phone' => $groupsPriceInquiry->getPhone(),
'remarks' => $groupsPriceInquiry->getRemarks(),
'dateFrom' => $groupsPriceInquiry->getDateFrom(),
'dateTo' => $groupsPriceInquiry->getDateTo(),
'nights' => $groupsPriceInquiry->getNights(),
'pax' => $groupsPriceInquiry->getPax(),
'children' => $groupsPriceInquiry->getChildren(),
'minors' => $groupsPriceInquiry->getMinors(),
'adolescents' => $groupsPriceInquiry->getAdolescents(),
'adolescentsAge' => $hotel->getGroupsPriceAdolescentsAge(),
'options' => $selectedOptions,
'board' => $groupsPriceInquiry->getBoard(),
'summary' => $groupsPriceInquiry->getSummary(),
];
$this->emailService->send($emailOptions, $variables);
return json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR, 512);
}
/**
* @return string
*/
protected function errorAction() {
$formErrors = [];
if ($this->arguments->validate()->hasErrors()) {
foreach ($this->arguments->validate()->getFlattenedErrors() as $key => $errors) {
$parts = explode('.', $key);
$fieldName = array_pop($parts);
$errorsRaw = [];
foreach ($errors as $error) {
$translationKey = sprintf('tx_eptheme.message.%s.%s', $fieldName, $error->getCode());
$errorsRaw[] = LocalizationUtility::translate($translationKey, 'ep_theme');
}
$formErrors[$fieldName] = implode(', ', $errorsRaw);
}
}
$response['status'] = 'validation';
$response['errors'] = $formErrors;
return json_encode($response);
}
}
@@ -1,67 +0,0 @@
<?php
namespace EP\EpProducts\Controller;
/***************************************************************
*
* Copyright notice
*
* (c) 2020 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Hotel;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class GroupsPriceController extends ActionController
{
public function indexAction(Hotel $hotel)
{
// Sort configs by date to properly determine earliest date in js component
$configs = $hotel->getGroupsPriceConfigs()->toArray();
uasort($configs, function ($a, $b) {
return $a->getDateFrom() > $b->getDateFrom();
});
$mainSeasonFrom = $mainSeasonTo = null;
if (null !== $hotel->getMainSeasonFrom()) {
$mainSeasonFrom = $hotel->getMainSeasonFrom()->format('d.m.Y');
}
if (null !== $hotel->getMainSeasonTo()) {
$mainSeasonTo = $hotel->getMainSeasonTo()->format('d.m.Y');
}
$this->view->assign('hotel', $hotel);
$this->view->assign('runningCostsEUR', $this->settings['runningCostsEUR']);
$this->view->assign('runningCostsCHF', $this->settings['runningCostsCHF']);
$this->view->assign('undersubscription30EUR', $this->settings['undersubscription30EUR']);
$this->view->assign('undersubscription30CHF', $this->settings['undersubscription30CHF']);
$this->view->assign('undersubscription40EUR', $this->settings['undersubscription40EUR']);
$this->view->assign('undersubscription40CHF', $this->settings['undersubscription40CHF']);
$this->view->assign('countryCode', $hotel->getCountry()->getCode());
$this->view->assign('configs', json_encode(array_values($configs), JSON_HEX_APOS));
$this->view->assign('boards', json_encode($hotel->getGroupsPriceBoards()->toArray(), JSON_HEX_APOS));
$this->view->assign('options', json_encode($hotel->getGroupsPriceOptions()->toArray(), JSON_HEX_APOS));
$this->view->assign('mainSeasonFrom', $mainSeasonFrom);
$this->view->assign('mainSeasonTo', $mainSeasonTo);
$this->view->assign('adolescentsAge', $hotel->getGroupsPriceAdolescentsAge() ?? 0);
}
}
@@ -36,6 +36,7 @@ use EP\EpProducts\Service\ContingentDataService;
use EP\EpProducts\Service\DateService; use EP\EpProducts\Service\DateService;
use EP\EpProducts\Service\FilterService; use EP\EpProducts\Service\FilterService;
use EP\EpProducts\Service\HotelDataService; use EP\EpProducts\Service\HotelDataService;
use EP\EpProducts\Service\PickupOverviewService;
use EP\EpProducts\Service\ProductDataService; use EP\EpProducts\Service\ProductDataService;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
@@ -82,6 +83,11 @@ class ProductController extends ActionController
*/ */
private $infoboxRepository; private $infoboxRepository;
/**
* @var PickupOverviewService
*/
private $pickupOverviewService;
/** /**
* @param ProductRepository $productRepository * @param ProductRepository $productRepository
* @param ProductDataService $productDataService * @param ProductDataService $productDataService
@@ -91,6 +97,7 @@ class ProductController extends ActionController
* @param DateService $dateService * @param DateService $dateService
* @param FilterService $filterService * @param FilterService $filterService
* @param InfoboxRepository $infoboxRepository * @param InfoboxRepository $infoboxRepository
* @param PickupOverviewService $pickupOverviewService
*/ */
public function __construct( public function __construct(
ProductRepository $productRepository, ProductRepository $productRepository,
@@ -100,7 +107,8 @@ class ProductController extends ActionController
ContingentDataService $contingentDataService, ContingentDataService $contingentDataService,
DateService $dateService, DateService $dateService,
FilterService $filterService, FilterService $filterService,
InfoboxRepository $infoboxRepository InfoboxRepository $infoboxRepository,
PickupOverviewService $pickupOverviewService
) { ) {
$this->productRepository = $productRepository; $this->productRepository = $productRepository;
$this->productDataService = $productDataService; $this->productDataService = $productDataService;
@@ -110,6 +118,7 @@ class ProductController extends ActionController
$this->dateService = $dateService; $this->dateService = $dateService;
$this->filterService = $filterService; $this->filterService = $filterService;
$this->infoboxRepository = $infoboxRepository; $this->infoboxRepository = $infoboxRepository;
$this->pickupOverviewService = $pickupOverviewService;
} }
/** /**
@@ -196,6 +205,7 @@ class ProductController extends ActionController
'referringUrl' => $backlinkUrl, 'referringUrl' => $backlinkUrl,
'backlinkLabel' => $backLinkLabel, 'backlinkLabel' => $backLinkLabel,
'nonBookableInfo' => $nonBookableInfo, 'nonBookableInfo' => $nonBookableInfo,
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
]); ]);
} }
@@ -87,13 +87,17 @@ class SearchController extends ActionController
public function initializeSearchresultAction() public function initializeSearchresultAction()
{ {
// Process encoded filtersettings from static search result // Process encoded filtersettings from static search result.
// Explicit filter settings win: they carry the visitor's current selection,
// whereas a stale 'f' riding along on a link would silently replace it.
$encodedFilterSettings = GeneralUtility::_GET('f'); $encodedFilterSettings = GeneralUtility::_GET('f');
if ($encodedFilterSettings) { if ($encodedFilterSettings && !$this->request->hasArgument('filterSettings')) {
$filterSettingsEncoder = new FilterSettingsEncoder(); $filterSettingsEncoder = new FilterSettingsEncoder();
$filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings); $filterSettings = $filterSettingsEncoder->decode($encodedFilterSettings);
if (count($filterSettings) > 0) {
$this->request->setArgument('filterSettings', $filterSettings); $this->request->setArgument('filterSettings', $filterSettings);
} }
}
$forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true); $forcedConceptUids = GeneralUtility::intExplode(',', $this->settings['forcedConceptUids'], true);
$this->processFilterSettingsArgument($forcedConceptUids); $this->processFilterSettingsArgument($forcedConceptUids);
} }
@@ -56,7 +56,7 @@ class FacebookCatalogCsvFormatter
$product['images']['image'][0], $product['images']['image'][0],
$product['link'], $product['link'],
$product['name'], $product['name'],
$minPrice . ' EUR', sprintf('%.2f EUR', $minPrice),
'brand-epreisen' 'brand-epreisen'
]; ];
} }
@@ -48,7 +48,7 @@ class GoogleBusinessFeedCsvFormatter
$product['name'], $product['name'],
$product['link'], $product['link'],
$product['images']['image'][0], $product['images']['image'][0],
$minPrice . '.00 EUR', sprintf('%.2f EUR', $minPrice),
]; ];
} }
} }
@@ -114,15 +114,20 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
*/ */
protected $skipassIncluded = false; protected $skipassIncluded = false;
/**
* @var bool
*/
protected $groupTravel = false;
/** /**
* @var bool * @var bool
*/ */
protected $busAvailable = false; protected $busAvailable = false;
/** /**
* @var int * @var float
*/ */
protected $busPrice = 0; protected $busPrice = 0.0;
/** /**
* @var string * @var string
@@ -145,19 +150,19 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
protected $pickups = ''; protected $pickups = '';
/** /**
* @var int * @var float
*/ */
protected $discount = 0; protected $discount = 0.0;
/** /**
* @var int * @var float
*/ */
protected $minPrice = 0; protected $minPrice = 0.0;
/** /**
* @var int * @var float
*/ */
protected $pseudoPrice = 0; protected $pseudoPrice = 0.0;
/** /**
* @var int * @var int
@@ -421,6 +426,22 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
$this->skipassIncluded = $skipassIncluded; $this->skipassIncluded = $skipassIncluded;
} }
/**
* @return bool
*/
public function getGroupTravel()
{
return $this->groupTravel;
}
/**
* @param bool $groupTravel
*/
public function setGroupTravel($groupTravel)
{
$this->groupTravel = $groupTravel;
}
/** /**
* @return bool * @return bool
*/ */
@@ -438,7 +459,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getBusPrice() public function getBusPrice()
{ {
@@ -446,7 +467,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $busPrice * @param float $busPrice
*/ */
public function setBusPrice($busPrice) public function setBusPrice($busPrice)
{ {
@@ -533,21 +554,21 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getDiscount() { public function getDiscount() {
return $this->discount; return $this->discount;
} }
/** /**
* @param int $discount * @param float $discount
*/ */
public function setDiscount($discount) { public function setDiscount($discount) {
$this->discount = $discount; $this->discount = $discount;
} }
/** /**
* @return int * @return float
*/ */
public function getMinPrice() public function getMinPrice()
{ {
@@ -555,7 +576,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $minPrice * @param float $minPrice
*/ */
public function setMinPrice($minPrice) public function setMinPrice($minPrice)
{ {
@@ -563,7 +584,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @return int * @return float
*/ */
public function getPseudoPrice() public function getPseudoPrice()
{ {
@@ -571,7 +592,7 @@ class Date extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
} }
/** /**
* @param int $pseudoPrice * @param float $pseudoPrice
*/ */
public function setPseudoPrice($pseudoPrice) public function setPseudoPrice($pseudoPrice)
{ {
@@ -27,6 +27,7 @@ namespace EP\EpProducts\Domain\Model;
* This copyright notice MUST APPEAR in all copies of the script! * This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/ ***************************************************************/
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference; use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage; use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
@@ -1253,4 +1254,20 @@ class Hotel extends AbstractEntity implements TeaserInterface
{ {
$this->distanceLift = $distanceLift; $this->distanceLift = $distanceLift;
} }
public function getIcons(): array
{
return HotelIconUtility::getIcons([
'beds_count' => $this->bedsCount,
'parking_count' => $this->parkingCount,
'altitude' => $this->altitude,
'distance_bus' => $this->distanceBus,
'distance_lift' => $this->distanceLift,
'shower' => $this->shower,
'partyroom' => $this->partyroom,
'gamesroom' => $this->gamesroom,
'sauna' => $this->sauna,
'wifi' => $this->wifi,
]);
}
} }
@@ -30,12 +30,17 @@ namespace EP\EpProducts\Domain\Model;
class Room class Room
{ {
const ROOM_TYPE_UNCLASSIFIED = 0;
const ROOM_TYPE_FOR_ONE = 1; const ROOM_TYPE_FOR_ONE = 1;
const ROOM_TYPE_FOR_TWO = 2; const ROOM_TYPE_FOR_TWO = 2;
const ROOM_TYPE_FOR_THREE = 3; const ROOM_TYPE_FOR_THREE = 3;
const ROOM_TYPE_FOR_FOUR = 4; const ROOM_TYPE_FOR_FOUR = 4;
const ROOM_TYPE_WITH_OTHERS = 5; const ROOM_TYPE_FOR_FIVE = 5;
const ROOM_TYPE_OTHER = 6; const ROOM_TYPE_FOR_SIX = 6;
const ROOM_TYPE_FOR_SEVEN = 7;
const ROOM_TYPE_FOR_EIGHT = 8;
const ROOM_TYPE_FOR_NINE_PLUS = 9;
const ROOM_TYPE_WITH_OTHERS = 10;
const ROOM_STATUS_AVAILABLE = 1; const ROOM_STATUS_AVAILABLE = 1;
const ROOM_STATUS_ON_REQUEST = 2; const ROOM_STATUS_ON_REQUEST = 2;
@@ -1,75 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Model;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMapping extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
/**
* @var string
*/
protected $code = '';
/**
* @var int
*/
protected $type = 0;
/**
* @return string
*/
public function getCode()
{
return $this->code;
}
/**
* @param string $code
*/
public function setCode($code)
{
$this->code = $code;
}
/**
* @return int
*/
public function getType()
{
return $this->type;
}
/**
* @param int $type
*/
public function setType($type)
{
$this->type = $type;
}
}
@@ -3,6 +3,7 @@
namespace EP\EpProducts\Domain\Repository; namespace EP\EpProducts\Domain\Repository;
use EP\EpProducts\Domain\Model\Product; use EP\EpProducts\Domain\Model\Product;
use TYPO3\CMS\Core\Database\Connection;
class DateRepository extends AbstractRepository class DateRepository extends AbstractRepository
{ {
@@ -18,4 +19,27 @@ class DateRepository extends AbstractRepository
->execute() ->execute()
; ;
} }
/**
* @return string[] serialized pickups of each upcoming date, latest first
*/
public function findPickupsForProductAndHotel(int $productUid, int $hotelUid): array
{
$qb = $this->getDbConnection()->createQueryBuilder();
return $qb
->select('date.pickups')
->from('tx_epproducts_domain_model_date', 'date')
->where(
$qb->expr()->eq('date.product', $qb->createNamedParameter($productUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.hotel', $qb->createNamedParameter($hotelUid, Connection::PARAM_INT)),
$qb->expr()->eq('date.group_travel', $qb->createNamedParameter(0, Connection::PARAM_INT)),
'date.date_start >= NOW()'
)
->orderBy('date.date_start', 'DESC')
->addOrderBy('date.uid', 'DESC')
->execute()
->fetchFirstColumn()
;
}
} }
@@ -76,9 +76,11 @@ class ProductRepository extends AbstractRepository
'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded', 'date.services_included as servicesIncluded', 'date.skipass_included as skiPassIncluded',
'date.bus_available as busAvailable', 'date.new as new', 'date.bus_available as busAvailable', 'date.new as new',
'date.min_price as minPrice', 'date.nights as nights', 'date.available as available', 'date.min_price as minPrice', 'date.nights as nights', 'date.available as available',
'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton', 'date.pseudo_price as pseudoPrice', 'date.hide_booking_button as hideBookingButton'
'room.type as roomType'
) )
// The GROUP BY below collapses many room rows per group; an arbitrary
// room.type would hide most types from the filter facet.
->addSelectLiteral('GROUP_CONCAT(DISTINCT room.type) as roomTypes')
->from('tx_epproducts_domain_model_date', 'date') ->from('tx_epproducts_domain_model_date', 'date')
->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid') ->leftJoin('date', 'tx_epproducts_domain_model_concept', 'con', 'date.concept = con.uid')
->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid') ->leftJoin('date', 'tx_epproducts_domain_model_room', 'room', 'room.date = date.uid')
@@ -208,6 +210,7 @@ class ProductRepository extends AbstractRepository
'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd', 'date.uid as dateUid', 'date.date_start as dateStart', 'date.date_end as dateEnd',
'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel as hotelUid', 'date.hotel_bus_pro_id as hotelBusProId',
'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded', 'date.bus_included as dateBusIncluded', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.season as season', 'date.product_detail_page as productDetailPage', 'date.season as season', 'date.product_detail_page as productDetailPage',
'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName', 'room.uid as roomUid', 'room.bus_pro_id as roomBusProId', 'room.name as roomName',
'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice', 'room.price as roomPrice', 'date.discount as roomDiscount', 'date.bus_price as busPrice',
@@ -271,6 +274,7 @@ class ProductRepository extends AbstractRepository
'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights', 'date.discount as dateDiscount', 'date.bus_price as busPrice', 'date.nights as dateNights',
'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded', 'date.bus_included as dateBusIncluded', 'date.services_included as dateServicesIncluded',
'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded', 'date.services_general as dateServicesGeneral', 'date.skipass_included as dateSkiPassIncluded',
'date.group_travel as dateGroupTravel',
'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId', 'date.bus_pro_id as dateBusProId', 'date.hotel_bus_pro_id as hotelBusProId',
'date.available as available', 'date.product_detail_page as productDetailPage', 'date.available as available', 'date.product_detail_page as productDetailPage',
) )
@@ -1,31 +0,0 @@
<?php
namespace EP\EpProducts\Domain\Repository;
/***************************************************************
*
* Copyright notice
*
* (c) 2016 Björn Fromme <[email protected]>, dreipunktnull
*
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
class RoomMappingRepository extends AbstractRepository
{}
@@ -44,7 +44,7 @@ class Date
$date['@id'] = $array['dateBusProId']; $date['@id'] = $array['dateBusProId'];
$date['from'] = $array['dateStart']; $date['from'] = $array['dateStart'];
$date['to'] = $array['dateEnd']; $date['to'] = $array['dateEnd'];
$date['minprice'] = $array['dateMinPrice']; $date['minprice'] = (float) $array['dateMinPrice'];
$date['nights'] = $array['dateNights']; $date['nights'] = $array['dateNights'];
$date['skipassincluded'] = $array['dateSkiPassIncluded']; $date['skipassincluded'] = $array['dateSkiPassIncluded'];
$date['busincluded'] = $array['dateBusIncluded']; $date['busincluded'] = $array['dateBusIncluded'];
@@ -40,8 +40,8 @@ class Room
$room['@pax'] = $array['roomPax']; $room['@pax'] = $array['roomPax'];
$room['@code'] = $array['roomCode']; $room['@code'] = $array['roomCode'];
$room['name'] = $array['roomName']; $room['name'] = $array['roomName'];
$room['price'] = $array['roomPrice']; $room['price'] = (float) $array['roomPrice'];
$room['busprice'] = $array['busPrice']; $room['busprice'] = (float) $array['busPrice'];
$room['nights'] = $array['roomNights']; $room['nights'] = $array['roomNights'];
$room['available'] = $array['roomAvailable']; $room['available'] = $array['roomAvailable'];
@@ -0,0 +1,244 @@
<?php
namespace EP\EpProducts\EpTeam;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The MyE&P Team application (Symfony). Separate from EP\EpProducts\MyEP\ApiClient on purpose:
* this is a different application on its own host, authenticated with a shared key in a header
* instead of the OAuth client-credentials flow MyE&P uses, so it shares neither the base URI nor
* the token handling.
*/
class ApiClient implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Status codes the endpoint answers a stored submission with. A conflict is included for the
* dedupe the team application may add later - it is not returned today - because a
* double-clicked submit button is not something to report.
*/
private const ACCEPTED_STATUS_CODES = [
Response::HTTP_OK,
Response::HTTP_CREATED,
Response::HTTP_ACCEPTED,
Response::HTTP_NO_CONTENT,
Response::HTTP_CONFLICT,
];
/**
* How much of a rejected response is carried into the exception message. A 400 names the
* offending field in its body, which is the only way to tell from a log why a submission
* was refused.
*/
private const ERROR_BODY_EXCERPT_LENGTH = 500;
/**
* Shared, like the MyE&P client: Symfony's CurlHttpClient keeps its connection pool and TLS
* session cache per instance. The API key is a constant, so unlike a bearer token it can be
* a client default header and the instance can live as long as the request.
*/
private static ?HttpClientInterface $httpClient = null;
private ?array $configuration = null;
/**
* Posts a submitted TYPO3 form to POST /api/application-forms.
*
* @throws ApiException
*/
public function submitApplicationForm(array $payload): void
{
$uri = 'api/application-forms';
// 'json' also sets the Content-Type the endpoint requires - without it the body is not
// read as JSON and the answer is a 400.
$response = $this->request('POST', $uri, [
'json' => $payload,
]);
$statusCode = $this->readStatusCode($response, 'POST', $uri);
if (false === in_array($statusCode, self::ACCEPTED_STATUS_CODES, true)) {
$message = sprintf(
'EP Team API returned unexpected status %d for POST %s: %s',
$statusCode,
$uri,
$this->readErrorBody($response)
);
$this->logWarning($message);
throw new ApiException($message, $statusCode);
}
$this->logAcceptedSubmission($response, $payload);
}
/**
* @throws ApiException
*/
private function request(string $method, string $uri, array $options): ResponseInterface
{
try {
return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('EP Team API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
private function readErrorBody(ResponseInterface $response): string
{
try {
$body = trim($response->getContent(false));
} catch (TransportExceptionInterface $e) {
return '<no response body>';
}
if ('' === $body) {
return '<empty response body>';
}
if (mb_strlen($body) > self::ERROR_BODY_EXCERPT_LENGTH) {
$body = mb_substr($body, 0, self::ERROR_BODY_EXCERPT_LENGTH) . '…';
}
return $body;
}
/**
* The submission uuid the endpoint returns is what makes a support question ("did our
* application arrive?") answerable from the TYPO3 log alone. Reading the body also drains
* the response, which returns the connection to the pool instead of cancelling the transfer.
* Deliberately without any of the submitted values: these payloads are personal data.
*/
private function logAcceptedSubmission(ResponseInterface $response, array $payload): void
{
try {
$data = $response->toArray(false);
} catch (\Throwable $e) {
// A body that is absent (204) or not JSON costs nothing here - the submission is
// stored either way, which the asserted status has already established.
return;
}
if (null !== $this->logger) {
$this->logger->info('Application form submission accepted by the EP Team API.', [
'form' => $payload['form'] ?? null,
'pageUid' => $payload['pageUid'] ?? null,
'uuid' => $data['uuid'] ?? null,
]);
}
}
/**
* @throws ApiException
*/
private function getHttpClient(): HttpClientInterface
{
if (null !== static::$httpClient) {
return static::$httpClient;
}
$config = $this->getConfiguration();
$baseUrl = trim((string)($config['epTeamApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['epTeamApiKey'] ?? ''));
if ('' === $baseUrl) {
throw new ApiException('EP Team API base URL is not configured.');
}
if ('' === $apiKey) {
throw new ApiException('EP Team API key is not configured.');
}
if (0 !== strpos($baseUrl, 'https://')) {
// The application answers an http request with a 301 to the https URL, and this
// client does not follow redirects, so a plain http base URL fails every submission.
$this->logWarning(sprintf('EP Team API base URL is not https (%s); submissions will fail on the redirect.', $baseUrl));
}
$options = [
'headers' => [
'X-Api-Key' => $apiKey,
],
];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request - and this call happens
// inside the visitor's form submission, with the visitor waiting for it. The defaults
// live in code rather than in ext_conf_template.txt: ExtensionConfiguration::get()
// returns the stored configuration verbatim and only syncs the template when an
// extension has no configuration at all, so a newly added template key is absent until
// someone saves extension configuration by hand. Falling back to 0 there would silently
// mean "no timeout" - the opposite of what this guard is for. An explicit 0 still
// disables.
$timeout = (int)($config['epTeamApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['epTeamApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return static::$httpClient = HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
}
private function logError(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->error($message, $context);
}
}
private function logWarning(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->warning($message, $context);
}
}
}
@@ -0,0 +1,7 @@
<?php
namespace EP\EpProducts\EpTeam;
class ApiException extends \RuntimeException
{
}
@@ -0,0 +1,45 @@
<?php
namespace EP\EpProducts\Http;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
/**
* Checks the shared key of the CMS backchannel endpoints (content feed, CMS index).
*
* An empty or missing `cmsApiKey` denies every request. ExtensionConfiguration::get() returns the
* stored configuration verbatim, so a newly added key is absent until someone saves the extension
* configuration in the backend - defaulting to "allow" there would publish the whole catalogue.
*/
class ApiKeyAuthenticator
{
/**
* @var ExtensionConfiguration
*/
private $extensionConfiguration;
public function __construct(ExtensionConfiguration $extensionConfiguration)
{
$this->extensionConfiguration = $extensionConfiguration;
}
public function isAuthorized(ServerRequestInterface $request): bool
{
$configured = (string) ($this->extensionConfiguration->get('ep_products')['cmsApiKey'] ?? '');
if ('' === $configured) {
return false;
}
// Header first: query-string keys end up in access logs. `key` stays accepted for parity
// with the existing /api/hotel and /api/product calls.
$provided = $request->getHeaderLine('X-Api-Key');
if ('' === $provided) {
$provided = (string) ($request->getQueryParams()['key'] ?? '');
}
return hash_equals($configured, $provided);
}
}
@@ -2,7 +2,10 @@
namespace EP\EpProducts\Middleware; namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use EP\EpProducts\Service\MyEpImageService; use EP\EpProducts\Service\MyEpImageService;
use EP\EpProducts\Utility\HotelIconUtility;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
@@ -40,18 +43,32 @@ class ApiMiddleware implements MiddlewareInterface
*/ */
private $imageService; private $imageService;
/**
* @var ContentFeedApi
*/
private $contentFeedApi;
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
public function __construct( public function __construct(
MyEpImageService $hotelImageService, MyEpImageService $hotelImageService,
MyEpImageService $productImageService, MyEpImageService $productImageService,
MyEpImageService $regionImageService, MyEpImageService $regionImageService,
FileRepository $fileRepository, FileRepository $fileRepository,
ImageService $imageService ImageService $imageService,
ContentFeedApi $contentFeedApi,
ApiKeyAuthenticator $authenticator
) { ) {
$this->hotelImageService = $hotelImageService; $this->hotelImageService = $hotelImageService;
$this->productImageService = $productImageService; $this->productImageService = $productImageService;
$this->regionImageService = $regionImageService; $this->regionImageService = $regionImageService;
$this->fileRepository = $fileRepository; $this->fileRepository = $fileRepository;
$this->imageService = $imageService; $this->imageService = $imageService;
$this->contentFeedApi = $contentFeedApi;
$this->authenticator = $authenticator;
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -66,17 +83,17 @@ class ApiMiddleware implements MiddlewareInterface
return $this->handleProductRequest($request); return $this->handleProductRequest($request);
} }
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
return $this->contentFeedApi->handle($request);
}
return $handler->handle($request); return $handler->handle($request);
} }
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
{ {
// This is currently fake, we only check for existence. Since we are communicating if (!$this->authenticator->isAuthorized($request)) {
// on the backchannel only it's not much of a problem. return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$hotelCode = $request->getQueryParams()['hotel'] ?? null; $hotelCode = $request->getQueryParams()['hotel'] ?? null;
@@ -96,12 +113,8 @@ class ApiMiddleware implements MiddlewareInterface
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
{ {
// This is currently fake, we only check for existence. Since we are communicating if (!$this->authenticator->isAuthorized($request)) {
// on the backchannel only it's not much of a problem. return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
$apiKey = $request->getQueryParams()['key'] ?? null;
if (null === $apiKey) {
return new JsonResponse(['message' => 'No api key provided'], 403);
} }
$productCode = $request->getQueryParams()['product'] ?? null; $productCode = $request->getQueryParams()['product'] ?? null;
@@ -222,22 +235,11 @@ class ApiMiddleware implements MiddlewareInterface
'code' => $hotel['code'], 'code' => $hotel['code'],
'description' => $hotel['description'], 'description' => $hotel['description'],
'features' => $hotel['features'], 'features' => $hotel['features'],
'roomTypes' => $hotel['room_types'], 'room_types' => $hotel['room_types'],
'additionalInformation' => $hotel['additional_information'], 'additional_information' => $hotel['additional_information'],
'address' => $hotel['address'], 'address' => $hotel['address'],
'images' => $images, 'images' => $images,
'icons' => [ 'icons' => HotelIconUtility::getIcons($hotel),
'bedsCount' => (int) $hotel['beds_count'],
'parkingCount' => (int) $hotel['parking_count'],
'altitude' => $hotel['altitude'],
'shower' => $hotel['shower'],
'partyroom' => (bool) $hotel['partyroom'],
'gamesroom' => (bool) $hotel['gamesroom'],
'sauna' => (bool) $hotel['sauna'],
'wifi' => (bool) $hotel['wifi'],
'distanceBus' => $hotel['distance_bus'],
'distanceLift' => $hotel['distance_lift'],
],
'region' => $this->fetchRegionData((int) $hotel['region']), 'region' => $this->fetchRegionData((int) $hotel['region']),
]; ];
} }
@@ -0,0 +1,65 @@
<?php
namespace EP\EpProducts\Middleware;
use EP\EpProducts\ContentFeed\ContentFeedApi;
use EP\EpProducts\ContentFeed\ContentFeedConfiguration;
use EP\EpProducts\ContentFeed\ManifestBuilder;
use EP\EpProducts\Http\ApiKeyAuthenticator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
/**
* Keeps the chatbot content-only page type (an unstyled copy of every page) off the public web:
* only requests carrying the CMS API key get it.
*
* Runs once the page type is known and before TSFE, so a page-cache hit cannot bypass it.
* staticfilecache never stores the type (`tx_staticfilecache.disableCache`); if it did, .htaccess
* would serve the file without this check ever running.
*/
class ContentPageTypeGuardMiddleware implements MiddlewareInterface
{
/**
* @var ApiKeyAuthenticator
*/
private $authenticator;
/**
* @var ContentFeedConfiguration
*/
private $configuration;
public function __construct(ApiKeyAuthenticator $authenticator, ContentFeedConfiguration $configuration)
{
$this->authenticator = $authenticator;
$this->configuration = $configuration;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$routing = $request->getAttribute('routing');
if (!$routing instanceof PageArguments
|| (string) ManifestBuilder::CONTENT_PAGE_TYPE !== $routing->getPageType()
) {
return $handler->handle($request);
}
if (!$this->authenticator->isAuthorized($request)) {
// 404, not 403: an unauthenticated caller should not learn the type exists.
return GeneralUtility::makeInstance(ErrorController::class)
->pageNotFoundAction($request, 'The requested page does not exist');
}
if (!$this->configuration->isEnabled()) {
return ContentFeedApi::disabledResponse();
}
return $handler->handle($request);
}
}
@@ -16,6 +16,8 @@ use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -23,8 +25,50 @@ class ApiClient implements LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Cache identifier for the shared OAuth access token. The token is valid for the whole
* installation, so caching it here saves one round trip per request that would otherwise
* have to re-authenticate before it can fetch anything.
*/
private const ACCESS_TOKEN_CACHE_IDENTIFIER = 'myep_api_access_token';
/**
* Safety margin in seconds. A token that is about to expire is treated as expired so it
* cannot lapse between the cache read and the API call that uses it.
*/
private const ACCESS_TOKEN_EXPIRY_MARGIN = 60;
/**
* Fallback lifetime for tokens that do not report an expiry.
*/
private const ACCESS_TOKEN_DEFAULT_TTL = 300;
/**
* Status codes that mean the token was rejected rather than the request being wrong.
*/
private const UNAUTHORIZED_STATUS_CODES = [Response::HTTP_UNAUTHORIZED, Response::HTTP_FORBIDDEN];
private static ?AccessToken $accessToken = null; private static ?AccessToken $accessToken = null;
/**
* Shared on purpose. Symfony's CurlHttpClient keeps its connection pool and TLS session
* cache in a CurlClientState owned by the instance, so building a client per call throws
* both away and forces a fresh DNS lookup, TCP connect and TLS handshake every time -
* TravelinfoController alone makes two calls per page render. The bearer token is passed
* per request rather than as a client default so one instance can outlive one token.
*/
private static ?HttpClientInterface $httpClient = null;
private ?FrontendInterface $cache = null;
private ?array $configuration = null;
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -37,7 +81,8 @@ class ApiClient implements LoggerAwareInterface
} }
try { try {
return new \DateTimeImmutable($data['timestamp']); // The API reports Europe/Berlin local time without an offset.
return new \DateTimeImmutable($data['timestamp'], new \DateTimeZone('Europe/Berlin'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage()); $message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -45,6 +90,22 @@ class ApiClient implements LoggerAwareInterface
} }
} }
/**
* One page of the product export: one row per travel date × hotel in `data`,
* and `meta` describing the whole export.
*
* @throws ApiException
*/
public function getProductExport(int $page, int $perPage): array
{
return $this->requestJson('GET', 'exports/products', [
'query' => [
'page' => $page,
'perPage' => $perPage,
],
]);
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -70,7 +131,10 @@ class ApiClient implements LoggerAwareInterface
'json' => $addressData, 'json' => $addressData,
]); ]);
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT], 'POST', 'contactform'); // A conflict means the address is already registered, which is the normal answer to a
// resubmitted contact form and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform');
$this->drainResponse($response);
} }
/** /**
@@ -111,6 +175,31 @@ class ApiClient implements LoggerAwareInterface
return $this->requestJson('GET', 'pickups-planning/' . $travelCode); return $this->requestJson('GET', 'pickups-planning/' . $travelCode);
} }
/**
* @throws ApiException
*/
public function getPriceConfigData(string $hotelCode): array
{
return $this->requestJson('GET', 'contingents/calendar', [
'query' => [
'hotelCode' => $hotelCode,
],
]);
}
/**
* @throws ApiException
*/
public function getPriceTableData(string $hotelCode, string $year): array
{
return $this->requestJson('GET', 'contingents/prices', [
'query' => [
'hotelCode' => $hotelCode,
'year' => $year,
],
]);
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -128,6 +217,30 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function request(string $method, string $uri, array $options = []): ResponseInterface private function request(string $method, string $uri, array $options = []): ResponseInterface
{ {
$response = $this->sendRequest($method, $uri, $options);
if (false === in_array($this->readStatusCode($response, $method, $uri), self::UNAUTHORIZED_STATUS_CODES, true)) {
return $response;
}
// The token outlives a single PHP request, so one the API has stopped accepting - after
// a MyEP restart or a revoked client - would keep failing every call for the rest of its
// cached lifetime unless it is actively discarded here.
$this->logWarning(sprintf('MyEP API rejected the cached access token for %s %s, re-authenticating once', $method, $uri));
$this->discardAccessToken();
return $this->sendRequest($method, $uri, $options);
}
/**
* @throws ApiException
*/
private function sendRequest(string $method, string $uri, array $options): ResponseInterface
{
// Resolved here rather than baked into the client's default options so that the shared
// client survives a token change, and so the retry above picks up the fresh token.
$options['auth_bearer'] = $this->getAccessToken($this->getConfiguration())->getToken();
try { try {
return $this->getHttpClient()->request($method, $uri, $options); return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) { } catch (TransportExceptionInterface $e) {
@@ -144,13 +257,7 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void
{ {
try { $statusCode = $this->readStatusCode($response, $method, $uri);
$statusCode = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
if (true === in_array($statusCode, $expectedStatusCodes, true)) { if (true === in_array($statusCode, $expectedStatusCodes, true)) {
return; return;
@@ -162,6 +269,39 @@ class ApiClient implements LoggerAwareInterface
throw new ApiException($message, $statusCode); throw new ApiException($message, $statusCode);
} }
/**
* Reading the status completes the transfer, so this is also what turns the lazy response
* returned by the client into a finished request. Symfony keeps the status on the response,
* so calling this twice costs nothing.
*
* @throws ApiException
*/
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
{
try {
return $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* Reads and discards a body nobody else is going to look at. Destroying an unconsumed
* response cancels the transfer, which closes the connection instead of returning it to
* the pool, so the next call would have to handshake again.
*/
private function drainResponse(ResponseInterface $response): void
{
try {
$response->getContent(false);
} catch (TransportExceptionInterface $e) {
// The status has already been asserted, so a failure here costs nothing but the
// connection reuse this method exists for.
}
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -182,14 +322,58 @@ class ApiClient implements LoggerAwareInterface
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class) if (null !== static::$httpClient) {
return static::$httpClient;
}
$config = $this->getConfiguration();
$options = [];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['myEpApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return static::$httpClient = HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options);
}
/**
* Resolved lazily rather than injected: this class is also instantiated through Extbase's
* legacy object container (EP\EpTheme\Form\BpnApiFinisher asks for it via injectApiClient),
* and that container resolves constructor arguments by reflection, mapping an interface to
* a class name by stripping the "Interface" suffix (Container::getImplementationClassName()).
* A constructor typed against FrontendInterface therefore fatals there with
* "Class TYPO3\CMS\Core\Cache\Frontend\Frontend does not exist".
*/
private function getCache(): FrontendInterface
{
if (null === $this->cache) {
$this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('ep_products_cache');
}
return $this->cache;
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products'); ->get('ep_products');
}
static::$accessToken = $this->getAccessToken($config); return $this->configuration;
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [
'auth_bearer' => static::$accessToken->getToken(),
]);
} }
/** /**
@@ -197,12 +381,25 @@ class ApiClient implements LoggerAwareInterface
*/ */
private function getAccessToken(array $config): AccessToken private function getAccessToken(array $config): AccessToken
{ {
if (null !== static::$accessToken && false === static::$accessToken->hasExpired()) { if (true === $this->isTokenUsable(static::$accessToken)) {
return static::$accessToken; return static::$accessToken;
} }
// The static above only lives for one PHP request, so without this second level every
// single request would re-authenticate before it could fetch any data.
$cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
if (is_array($cachedToken) && isset($cachedToken['access_token'])) {
$token = new AccessToken($cachedToken);
if (true === $this->isTokenUsable($token)) {
static::$accessToken = $token;
return static::$accessToken;
}
}
try { try {
static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials'); static::$accessToken = $this->normalizeToken($this->getProvider($config)->getAccessToken('client_credentials'));
$this->cacheAccessToken(static::$accessToken);
} catch (IdentityProviderException $e) { } catch (IdentityProviderException $e) {
$message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage()); $message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -220,9 +417,69 @@ class ApiClient implements LoggerAwareInterface
return static::$accessToken; return static::$accessToken;
} }
/**
* AccessToken::hasExpired() throws for a token that reports no expiry, so it cannot be used
* as the check - a token without one is simply treated as unusable and replaced. The same
* margin as the cache lifetime is applied on read as well, so a token cannot lapse between
* this check and the API call that uses it, whichever node wrote the cache entry.
*/
private function isTokenUsable(?AccessToken $token): bool
{
if (null === $token) {
return false;
}
$expires = $token->getExpires();
if (null === $expires) {
return false;
}
return $expires - self::ACCESS_TOKEN_EXPIRY_MARGIN > time();
}
/**
* A token that reports no expiry cannot be validated on read - isTokenUsable() rejects it
* and AccessToken::hasExpired() throws for it - so it is given the fallback lifetime here,
* once, and the rest of the class can treat every token the same way.
*/
private function normalizeToken(AccessToken $token): AccessToken
{
if (null !== $token->getExpires()) {
return $token;
}
return new AccessToken($token->jsonSerialize() + ['expires_in' => self::ACCESS_TOKEN_DEFAULT_TTL]);
}
private function discardAccessToken(): void
{
static::$accessToken = null;
$this->getCache()->remove(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
}
private function cacheAccessToken(AccessToken $token): void
{
// normalizeToken() has already given a token without a reported expiry the fallback
// lifetime, so there is always a real expiry to subtract the margin from here.
$lifetime = $token->getExpires() - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN;
if ($lifetime <= 0) {
return;
}
$this->getCache()->set(self::ACCESS_TOKEN_CACHE_IDENTIFIER, $token->jsonSerialize(), [], $lifetime);
}
private function getProvider(array $config): AbstractProvider private function getProvider(array $config): AbstractProvider
{ {
return new GenericProvider([ return new GenericProvider([
// The OAuth round trip goes through the provider's own Guzzle client, not the
// Symfony client configured in getHttpClient(), so it needs its own timeout -
// otherwise the token request hangs indefinitely and the data-request timeout
// never gets a chance to apply. AbstractProvider::getAllowedClientOptions()
// forwards 'timeout' (and 'proxy') into Guzzle; 0 means unlimited there too.
'timeout' => (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT),
'clientId' => $config['myEpApiClientId'], 'clientId' => $config['myEpApiClientId'],
'clientSecret' => $config['myEpApiClientSecret'], 'clientSecret' => $config['myEpApiClientSecret'],
'redirectUri' => null, 'redirectUri' => null,
@@ -0,0 +1,233 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reads the product export from GET /api/exports/products on MyE&P. The rows
* already have the shape {@see DateImportService::import()} consumes, so they
* are passed on unchanged.
*
* Change detection compares GET /api/last-update against the
* `meta.sourceGeneratedAt` of the last successfully imported export, kept in
* var/transient/ep_products_import/api-imported.info. Both carry the BusPro
* export's own timestamp.
*/
final class ApiImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* The whole export is one page at current volumes; the walk does not rely on it.
*/
private const PER_PAGE = 500;
/**
* How often the walk restarts when MyE&P replaces its copy of the export
* mid-walk before giving up until the next run.
*/
private const MAX_WALKS = 3;
private const STAGING_SUBDIR = 'ep_products_import';
private const BASELINE_FILE = 'api-imported.info';
private const IMPORT_MARKER = 'fileadmin/products_import.info';
/**
* @var ApiClient|null
*/
private $apiClient;
public function __construct(?ApiClient $apiClient = null)
{
$this->apiClient = $apiClient;
}
/**
* The whole export is fetched before anything is returned, so a walk that
* has to restart never leaves a stitched dataset behind.
*
* @throws ApiException
*/
public function acquire(): ?ProductImport
{
if (!$this->isNewImportAvailable()) {
return null;
}
for ($walk = 1; $walk <= self::MAX_WALKS; $walk++) {
$export = $this->walkPages();
if ($export === false) {
continue;
}
if ($export === null) {
return null;
}
[$rows, $sourceGeneratedAt] = $export;
$baseline = $this->getImportedTimestamp();
if ($baseline !== null && $sourceGeneratedAt <= $baseline) {
return null;
}
return new ProductImport($rows, $sourceGeneratedAt);
}
throw new ApiException(sprintf(
'MyE&P replaced its product export during each of %d page walks, giving up until the next run',
self::MAX_WALKS
));
}
public function markImported(ProductImport $import): void
{
$sourceGeneratedAt = $import->getSourceGeneratedAt();
if ($sourceGeneratedAt !== null) {
$file = $this->baselinePath();
GeneralUtility::mkdir_deep(dirname($file));
GeneralUtility::writeFile($file, $sourceGeneratedAt->format(\DateTimeInterface::ATOM), false);
}
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
}
/**
* True when MyE&P reports an export newer than the last imported one, or
* nothing has been imported yet. A missing timestamp from MyE&P means
* "not importable now", never "import".
*
* @throws ApiException
*/
public function isNewImportAvailable(): bool
{
$lastUpdateAt = $this->getApiClient()->getLastUpdateAt();
if ($lastUpdateAt === null) {
return false;
}
$baseline = $this->getImportedTimestamp();
return $baseline === null || $lastUpdateAt > $baseline;
}
/**
* `meta.sourceGeneratedAt` of the last successfully imported export.
*/
public function getImportedTimestamp(): ?\DateTimeImmutable
{
$file = $this->baselinePath();
if (!is_file($file)) {
return null;
}
return $this->parseTimestamp(trim((string) file_get_contents($file)));
}
/**
* @return array{0: array[], 1: \DateTimeImmutable}|false|null the rows and
* the export's timestamp; null when the export is not importable now;
* false when the export changed mid-walk and the walk must restart
* @throws ApiException
*/
private function walkPages()
{
$rows = [];
$sourceGeneratedAt = null;
$page = 1;
do {
$body = $this->getApiClient()->getProductExport($page, self::PER_PAGE);
$meta = $body['meta'] ?? null;
if (!is_array($meta) || !isset($body['data']) || !is_array($body['data'])) {
throw new ApiException(sprintf('MyE&P product export page %d is malformed', $page));
}
$pageGeneratedAt = $this->parseTimestamp($meta['sourceGeneratedAt'] ?? null);
if ($page === 1) {
if ($pageGeneratedAt === null) {
$this->logWarning('MyE&P product export has no source timestamp; skipping product import');
return null;
}
$sourceGeneratedAt = $pageGeneratedAt;
} elseif ($pageGeneratedAt === null || $pageGeneratedAt != $sourceGeneratedAt) {
$this->logWarning('MyE&P product export changed during the page walk; restarting', [
'page' => $page,
'expected' => $sourceGeneratedAt->format(\DateTimeInterface::ATOM),
'actual' => $meta['sourceGeneratedAt'] ?? null,
]);
return false;
}
foreach ($body['data'] as $row) {
$rows[] = $row;
}
$totalPages = (int) ($meta['totalPages'] ?? 0);
$page++;
} while ($page <= $totalPages);
$total = (int) ($meta['total'] ?? -1);
if (count($rows) !== $total) {
throw new ApiException(sprintf(
'MyE&P product export is incomplete: %d rows received, %d announced',
count($rows),
$total
));
}
if ((int) ($meta['skipped'] ?? 0) > 0) {
$this->logWarning('MyE&P skipped travel dates with unparseable dates', ['skipped' => $meta['skipped']]);
}
if ((int) ($meta['pickupsWithoutMasterData'] ?? 0) > 0) {
$this->logWarning(
'MyE&P product export has pickups without master data',
['pickupsWithoutMasterData' => $meta['pickupsWithoutMasterData']]
);
}
return [$rows, $sourceGeneratedAt];
}
/**
* @param mixed $value
*/
private function parseTimestamp($value): ?\DateTimeImmutable
{
if (!is_string($value) || $value === '') {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception $e) {
return null;
}
}
private function getApiClient(): ApiClient
{
if ($this->apiClient === null) {
$this->apiClient = GeneralUtility::makeInstance(ApiClient::class);
}
return $this->apiClient;
}
private function baselinePath(): string
{
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR . '/' . self::BASELINE_FILE;
}
private function logWarning(string $message, array $context = []): void
{
if ($this->logger !== null) {
$this->logger->warning($message, $context);
}
}
}
@@ -50,6 +50,42 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
const STATUS_ON_REQUEST = 'Anfrage'; const STATUS_ON_REQUEST = 'Anfrage';
const STATUS_STOP = 'Buchungsstop'; const STATUS_STOP = 'Buchungsstop';
/**
* BusPro ids of the selection groups whose selections make up the included
* services. Matched by id rather than by label, so a group renamed upstream
* does not silently drop out of services_included.
*/
const SELECTION_GROUP_SKIPASS = 7;
const SELECTION_GROUP_OTHER = 8;
const SELECTION_GROUP_BOARD = 71;
const SELECTION_GROUP_ACCOMMODATION = 85;
const SELECTION_GROUP_PROGRAMME = 86;
const SELECTION_GROUP_COURSES = 87;
const SELECTION_GROUP_EVENTS = 89;
const SELECTION_GROUP_TRANSPORTATION = 90;
/**
* Group 'Kunde' also carries trip types (Wochenreise, Midweekreise, ...), so
* group travel keys on the selection, not on the group's presence. Every
* date should carry either 'Gruppenkunde' or 'Endkunde'.
*/
const SELECTION_GROUP_CUSTOMER = 96;
const SELECTION_CUSTOMER_GROUP = 1418;
const SELECTION_CUSTOMER_END = 1419;
/**
* Fallback for dates the office has not tagged with either selection: these
* trip type markers in front of the travel code's trailing date mean
* end-customer travel.
*/
const END_CUSTOMER_CODE_PATTERN = '/(?:MP|WE|KT|PM|MW|FA)\d{6}$/';
/**
* 'Leistungen Personal' occurs in no export so far, so its id is unknown and
* it is still matched by label. Register its id once the warning fires.
*/
const SELECTION_GROUP_LABEL_PERSONNEL = 'Leistungen Personal';
/** /**
* @var ConfigurationManagerInterface * @var ConfigurationManagerInterface
*/ */
@@ -60,21 +96,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $pid = 0; protected $pid = 0;
/**
* @var array
*/
protected $roomMappings = [];
/** /**
* @var array * @var array
*/ */
protected $hotelMappings = []; protected $hotelMappings = [];
/**
* @var array
*/
protected $pickups = [];
/** /**
* @var Connection * @var Connection
*/ */
@@ -85,6 +111,16 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
*/ */
protected $cacheService; protected $cacheService;
/**
* @var ProductImportSourceFactory
*/
protected $importSourceFactory;
/**
* @var RoomTypeResolver
*/
protected $roomTypeResolver;
/** /**
* @var array * @var array
*/ */
@@ -95,10 +131,19 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param ConfigurationManagerInterface $configurationManager * @param ConfigurationManagerInterface $configurationManager
* @param CacheService $cacheService * @param CacheService $cacheService
* @param ProductImportSourceFactory $importSourceFactory
* @param RoomTypeResolver $roomTypeResolver
*/ */
public function __construct(ConfigurationManagerInterface $configurationManager, CacheService $cacheService) { public function __construct(
ConfigurationManagerInterface $configurationManager,
CacheService $cacheService,
ProductImportSourceFactory $importSourceFactory,
RoomTypeResolver $roomTypeResolver
) {
$this->configurationManager = $configurationManager; $this->configurationManager = $configurationManager;
$this->cacheService = $cacheService; $this->cacheService = $cacheService;
$this->importSourceFactory = $importSourceFactory;
$this->roomTypeResolver = $roomTypeResolver;
$settings = GeneralUtility::removeDotsFromTS( $settings = GeneralUtility::removeDotsFromTS(
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT) $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
@@ -112,19 +157,18 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param string $path * @param iterable<array> $rows one row per travel date × hotel, in the shape
* of GET /api/exports/products on MyE&P
* @return int * @return int
* @throws \Doctrine\DBAL\DBALException * @throws \Doctrine\DBAL\DBALException
*/ */
public function import($path): int public function import(iterable $rows): int
{ {
$this->db = $this->getDbConnection(); $this->db = $this->getDbConnection();
$this->getRoomMappings();
$this->getHotelMappings(); $this->getHotelMappings();
$this->importPickups();
$this->createTempTables(); $this->createTempTables();
$this->logger->info('starting product import'); $this->logger->info('starting product import');
$dateCount = $this->parseXmlFilesForProducts($path); $dateCount = $this->importProducts($rows);
$this->clearTables(); $this->clearTables();
$this->copyTables(); $this->copyTables();
$this->clearTempTables(); $this->clearTempTables();
@@ -135,70 +179,47 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
public function isImportRequired(): bool public function isImportRequired(): bool
{ {
$timestampService = new ImportTimestampService(); return $this->importSourceFactory->get()->isNewImportAvailable();
return $timestampService->isImportRequired(
'fileadmin/xmlexport/uebertragung.info',
'fileadmin/products_import.info'
);
} }
/** /**
* @param $path * Groups the rows by product and travel date, in the order they first
* appear, because a product code can map to more than one TYPO3 product and
* each of them imports all of the product's dates.
* *
* @return bool * @param iterable<array> $rows
* @return int
*/ */
public function checkActiveUpload($path): bool protected function importProducts(iterable $rows): int
{ {
$tempFile = $path . '/.pureftpd-upload.*'; $products = [];
foreach ($rows as $row) {
return count(glob($tempFile)) > 0; $code = $row['product']['code'];
if (!isset($products[$code])) {
$products[$code] = ['busProId' => (int) $row['product']['busProId'], 'dates' => []];
}
$products[$code]['dates'][(int) $row['date']['busProId']][] = $row;
} }
public function parseXmlFilesForProducts($path): int
{
$dateCount = 0; $dateCount = 0;
foreach ($products as $code => $product) {
$folder = opendir($path); $dateCount += $this->importProduct((string) $code, $product['busProId'], $product['dates']);
while (($fileName = readdir($folder)) !== false) {
if (strpos($fileName, 'Ziel') === 0) {
$filePath = $path . '/' . $fileName;
$dateCount += $this->parseProductXmlFile($filePath);
}
} }
return $dateCount; return $dateCount;
} }
/** /**
* @param string $file * Recreated on every run, so the temp tables always follow the schema of the
* @return int * live tables (a column type change would otherwise survive in them) and a
* crashed import cannot leave rows behind for the next one.
*/ */
public function parseProductXmlFile($file): int
{
if (!file_exists($file)) {
$this->logger->error('product xml not found', ['file' => $file]);
return 0;
}
libxml_use_internal_errors(true);
/** @var \SimpleXMLElement $xmlData */
if (!$xmlData = simplexml_load_string(file_get_contents($file))) {
libxml_clear_errors();
return 0;
}
if ($xmlData->reise[0] === null) {
return 0;
}
return $this->parseProduct($xmlData->reise[0]);
}
public function createTempTables(): void public function createTempTables(): void
{ {
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date'); $this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_date_temp');
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room'); $this->db->executeStatement('DROP TABLE IF EXISTS tx_epproducts_domain_model_room_temp');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_date_temp LIKE tx_epproducts_domain_model_date');
$this->db->executeStatement('CREATE TABLE tx_epproducts_domain_model_room_temp LIKE tx_epproducts_domain_model_room');
} }
public function clearTables(): void public function clearTables(): void
@@ -220,13 +241,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlProduct * @param string $code
* @param int $busProId
* @param array[][] $dates rows grouped by travel date
* @return int * @return int
*/ */
protected function parseProduct(\SimpleXMLElement $xmlProduct): int protected function importProduct(string $code, int $busProId, array $dates): int
{ {
$code = (string) $xmlProduct->attributes()['code'];
$busProId = (int) $xmlProduct->attributes()['idbuspro'];
$dateCount = 0; $dateCount = 0;
$sql = ' $sql = '
@@ -264,8 +285,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$product['bus_pro_id'] = $busProId; $product['bus_pro_id'] = $busProId;
$product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']); $product['product_fact'] = $this->getTopFactForRecord('tx_epproducts_product_fact_mm', $product['uid']);
$product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']); $product['region_fact'] = $this->getTopFactForRecord('tx_epproducts_region_fact_mm', $product['region']);
foreach ($xmlProduct->termin as $xmlDate) { foreach ($dates as $rows) {
$dateCount += $this->parseDates($xmlDate, $product); $dateCount += $this->importDate($rows, $product);
} }
} }
@@ -273,25 +294,21 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlDate * @param array[] $rows one per hotel of the travel date
* @param array $product * @param array $product
* @return int * @return int
*/ */
protected function parseDates(\SimpleXMLElement $xmlDate, array $product): int protected function importDate(array $rows, array $product): int
{ {
$dateCount = 0; $dateCount = 0;
// $earliestDate = new \DateTimeImmutable('next year');
foreach ($xmlDate->hotel as $xmlHotel) { // Selection groups are date-level, so any hotel row tells.
// don't import dates starting later than one year from now $this->checkCustomerSelection(reset($rows));
// $dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']);
// if ($dateStart > $earliestDate) {
// continue;
// }
$date = $this->createDateRecord($product, $xmlDate); foreach ($rows as $row) {
$date = $this->createDateRecord($product, $row);
$hotelCode = (string) $xmlHotel->attributes()['code']; $hotelCode = $row['hotel']['code'];
$key = $hotelCode . $date['date_start']; $key = $hotelCode . $date['date_start'];
// Import daytrip dates only once per hotel as room information is taken // Import daytrip dates only once per hotel as room information is taken
@@ -300,7 +317,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
continue; continue;
} }
if ($this->parseHotel($xmlHotel, $date) !== false) { if ($this->parseHotel($row, $date) !== false) {
$this->db->insert( $this->db->insert(
'tx_epproducts_domain_model_date_temp', 'tx_epproducts_domain_model_date_temp',
$date $date
@@ -326,17 +343,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $product * @param array $product
* @param \SimpleXMLElement $xmlDate * @param array $row
* @return array * @return array
*/ */
protected function createDateRecord(array $product, \SimpleXMLElement $xmlDate): array protected function createDateRecord(array $product, array $row): array
{ {
$dateStart = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['termin']); $dateStart = \DateTime::createFromFormat('Y-m-d', $row['date']['from']);
$dateEnd = \DateTime::createFromFormat('d.m.Y', (string) $xmlDate->attributes()['bis']); $dateEnd = \DateTime::createFromFormat('Y-m-d', $row['date']['to']);
$nights = $dateStart->diff($dateEnd)->format('%d'); $nights = $dateStart->diff($dateEnd)->format('%d');
$busProId = (int) $xmlDate->attributes()['idbuspro']; $busProId = (int) $row['date']['busProId'];
$code = (string) $xmlDate->attributes()['code']; $code = (string) $row['date']['code'];
$date = [ $date = [
'pid' => $this->pid, 'pid' => $this->pid,
@@ -379,15 +396,15 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
'important' => $product['important'], 'important' => $product['important'],
]; ];
$includedAccommodation = $this->parseSelectionGroup($xmlDate, 'Leistungen Übernachtung'); $includedAccommodation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_ACCOMMODATION);
$includedSkiPass = $this->parseSelectionGroup($xmlDate, 'Leistungen Skipass'); $includedSkiPass = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_SKIPASS);
$includedBoard = $this->parseSelectionGroup($xmlDate, 'Leistungen Verpflegung'); $includedBoard = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_BOARD);
$includedTransportation = $this->parseSelectionGroup($xmlDate, 'Leistungen Transport'); $includedTransportation = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_TRANSPORTATION);
$includedProgramme = $this->parseSelectionGroup($xmlDate, 'Leistungen Programm'); $includedProgramme = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_PROGRAMME);
$includedPersonnel = $this->parseSelectionGroup($xmlDate, 'Leistungen Personal'); $includedPersonnel = $this->parsePersonnelSelectionGroup($row['selectionGroups']);
$includedEvents = $this->parseSelectionGroup($xmlDate, 'Leistungen Events'); $includedEvents = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_EVENTS);
$includedOther = $this->parseSelectionGroup($xmlDate, 'Leistungen Sonstige'); $includedOther = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_OTHER);
$includedCourses = $this->parseSelectionGroup($xmlDate, 'Leistungen Kurse'); $includedCourses = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_COURSES);
$includedServices = [ $includedServices = [
...$includedAccommodation, ...$includedAccommodation,
@@ -412,7 +429,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$skiPassIncluded = (int) (count($includedSkiPass) > 0); $skiPassIncluded = (int) (count($includedSkiPass) > 0);
$date['skipass_included'] = $skiPassIncluded; $date['skipass_included'] = $skiPassIncluded;
$servicesTransportation = $this->parseService($xmlDate, 'lei_befoerderung'); $date['group_travel'] = (int) $this->isGroupTravel($row);
$servicesTransportation = $this->parseService($row['transportServices']);
$discount = $this->determineDiscount($servicesTransportation); $discount = $this->determineDiscount($servicesTransportation);
$date['discount'] = $discount; $date['discount'] = $discount;
@@ -434,11 +453,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
if ($discount) { if ($discount) {
$servicesTransportationOptional[] = [ $servicesTransportationOptional[] = [
'label' => 'Rabatt bei Eigenanreise', 'label' => 'Rabatt bei Eigenanreise',
'price' => $discount, 'price' => number_format($discount, 2, ',', '.'),
]; ];
} }
$servicesGeneral = $this->parseService($xmlDate, 'lei_sonstiges'); $servicesGeneral = $this->parseService($row['dateServices']);
$combinedServices = []; $combinedServices = [];
@@ -460,29 +479,22 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$date['services_general'] = serialize($combinedServices); $date['services_general'] = serialize($combinedServices);
$pickups = []; $pickups = [];
foreach ($xmlDate->xpath('zustiege/zustieg') as $pickup) { foreach ($row['pickups'] as $pickup) {
$attributes = $pickup->attributes(); $busProId = (int) $pickup['busProId'];
if (null === $attributes) {
continue;
}
$busProId = (int) $attributes['idbuspro'];
if ($busProId === 0) { if ($busProId === 0) {
continue; continue;
} }
$timestamp = (string) $attributes['zeit'];
$parts = explode(' ', $timestamp);
if (2 === count($parts)) {
[$pickupDate, $timePart] = $parts;
$pickupTime = substr($timePart, 0, 5);
} else {
$pickupDate = $dateStart->format('d.m.Y'); $pickupDate = $dateStart->format('d.m.Y');
$pickupTime = $parts[0]; $pickupTime = '';
if ($pickup['datetime'] !== null) {
[$pickupDate, $pickupTime] = $this->parsePickupDateTime($pickup['datetime']);
} }
$pickups[] = [ $pickups[] = [
'bus_pro_id' => $busProId, 'bus_pro_id' => $busProId,
'city' => $this->pickups[$busProId]['city'], 'city' => $pickup['city'],
'street' => $this->pickups[$busProId]['street'], 'street' => $pickup['street'],
'price' => (float) str_replace(',', '.', (string) $attributes['preis']), // A pickup without a stated price has always been shown as included.
'price' => $pickup['price'] === null ? 0.0 : (float) $pickup['price'],
'date' => $pickupDate, 'date' => $pickupDate,
'time' => $pickupTime, 'time' => $pickupTime,
]; ];
@@ -499,21 +511,20 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
}); });
$date['pickups'] = serialize($pickups); $date['pickups'] = serialize($pickups);
$guide = $this->parseGuide($xmlDate); $date['guide'] = serialize($row['guide'] ?? []);
$date['guide'] = serialize($guide);
return $date; return $date;
} }
/** /**
* @param \SimpleXMLElement $xmlHotel * @param array $row
* @param array $date * @param array $date
* @return bool * @return bool
*/ */
protected function parseHotel(\SimpleXMLElement $xmlHotel, array &$date): bool protected function parseHotel(array $row, array &$date): bool
{ {
$code = (string) $xmlHotel->attributes()['code']; $code = $row['hotel']['code'];
$busProId = (string) $xmlHotel->attributes()['idbuspro']; $busProId = (int) $row['hotel']['busProId'];
if (!array_key_exists($code, $this->hotelMappings)) { if (!array_key_exists($code, $this->hotelMappings)) {
$this->logger->warning('hotel not found', ['code' => $code]); $this->logger->warning('hotel not found', ['code' => $code]);
@@ -526,7 +537,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services = []; $services = [];
$generalServices = unserialize($date['services_general']); $generalServices = unserialize($date['services_general']);
$hotelServices = $this->parseService($xmlHotel, 'lei_sonstiges', true); $hotelServices = $this->parseService($row['hotelServices'], true);
$this->addServices($generalServices, $services); $this->addServices($generalServices, $services);
$this->addServices($hotelServices, $services); $this->addServices($hotelServices, $services);
foreach ($services as $key => $list) { foreach ($services as $key => $list) {
@@ -536,7 +547,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$services[$key] = $list; $services[$key] = $list;
} }
$this->parseRooms($xmlHotel, $hotel['uid'], $date, $services); $this->parseRooms($row, $hotel['uid'], $date, $services);
$date['hotel'] = $hotel['uid']; $date['hotel'] = $hotel['uid'];
$date['hotel_code'] = $hotel['code']; $date['hotel_code'] = $hotel['code'];
@@ -555,108 +566,91 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return true; return true;
} }
protected function parseGuide(\SimpleXMLElement $xmlDate)
{
$transportationServicesBus = $xmlDate->xpath('lei_befoerderung/leistung[@unterart="BUS"]');
foreach ($transportationServicesBus as $service) {
if ('RUECK' === (string) $service->richtung) {
continue;
}
if (false === isset($service->zustiegsplanung->reiseleiter)) {
continue;
}
return [
'name' => (string) $service->zustiegsplanung->reiseleiter->name,
'phone' => (string) $service->zustiegsplanung->reiseleiter->telefon,
];
}
return [];
}
/** /**
* @param \SimpleXMLElement $xmlHotel * @param array $row
* @param int $hotelUid * @param int $hotelUid
* @param array $date * @param array $date
* @param array $services * @param array $services
*/ */
protected function parseRooms(\SimpleXMLElement $xmlHotel, $hotelUid, array &$date, array $services): void protected function parseRooms(array $row, $hotelUid, array &$date, array $services): void
{ {
$minPriceOverall = 0; $minPriceOverall = 0.0;
$minPriceAvailable = 0; $minPriceAvailable = 0.0;
$minPax = 0; $minPax = 0;
$availableTotal = 0; $availableTotal = 0;
$boardCode = ''; $boardCode = '';
$count = 0; $count = 0;
foreach ($xmlHotel->xpath('zimmer/preis') as $roomXml) { if ($row['pseudoPrice'] !== null) {
$code = trim((string) $roomXml->attributes()['zimmercode']); $date['pseudo_price'] = (float) $row['pseudoPrice'];
if ($code === static::CODE_PSEUDOPRICE) {
$price = (int) $roomXml->attributes()['preis'];
$date['pseudo_price'] = $price;
continue;
} }
$roomTypeKey = mb_strtolower($code); foreach ($row['rooms'] as $roomRow) {
if (array_key_exists($roomTypeKey, $this->roomMappings)) { $code = $roomRow['code'];
$roomType = $this->roomMappings[$roomTypeKey]; $label = $roomRow['label'];
} else { $roomMinPax = (int) $roomRow['minPax'];
$this->logger->warning('room type not found', ['code' => $code]); $pax = (int) $roomRow['maxPax'];
continue;
}
$available = (int) $roomXml->attributes()['verfuegbar']; $available = (int) $roomRow['available'];
$price = (int) $roomXml->attributes()['preis']; $price = (float) $roomRow['price'];
// Skip rooms with zero price _and_ zero availability. // Skip rooms with zero price _and_ zero availability.
if (!$available && !$price) { if (!$available && !$price) {
continue; continue;
} }
$pax = (int) $roomXml->attributes()['MinPax']; $roomType = $this->roomTypeResolver->resolve($code, $label, $pax, $roomMinPax);
if (0 === $minPax || $pax < $minPax) {
$minPax = $pax; if ($pax < 1 && $roomMinPax < 1) {
$this->logger->warning('room without occupancy', ['code' => $code, 'zimmertext' => $label]);
}
if ($this->roomTypeResolver->needsReview($code, $label, $pax)) {
$this->logger->warning(
'single-occupancy room without single-use marker',
['code' => $code, 'zimmertext' => $label]
);
}
if (0 === $minPax || $roomMinPax < $minPax) {
$minPax = $roomMinPax;
} }
$pax = (int) $roomXml->attributes()['MaxPax'];
$availableTotal += $available * $pax; $availableTotal += $available * $pax;
// Determine lowest price of available rooms // Determine lowest price of available rooms
if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0 || $price < $minPriceAvailable)) { if ($code !== static::CODE_BABYROOM && $available > 0 && ($minPriceAvailable === 0.0 || $price < $minPriceAvailable)) {
$minPriceAvailable = $price; $minPriceAvailable = $price;
} }
// Determine lowest overall price // Determine lowest overall price
if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0 || $price < $minPriceOverall)) { if ($code !== static::CODE_BABYROOM && ($minPriceOverall === 0.0 || $price < $minPriceOverall)) {
$minPriceOverall = $price; $minPriceOverall = $price;
} }
// Determine status // Determine status
$statusXml = (string) $roomXml->attributes()['status']; $sourceStatus = $roomRow['status'];
if (static::STATUS_STOP === $statusXml && $price) { if (static::STATUS_STOP === $sourceStatus && $price) {
$status = Room::ROOM_STATUS_FULLY_BOOKED; $status = Room::ROOM_STATUS_FULLY_BOOKED;
} elseif (static::STATUS_ON_REQUEST === $statusXml) { } elseif (static::STATUS_ON_REQUEST === $sourceStatus) {
$status = Room::ROOM_STATUS_ON_REQUEST; $status = Room::ROOM_STATUS_ON_REQUEST;
} else { } else {
$status = Room::ROOM_STATUS_AVAILABLE; $status = Room::ROOM_STATUS_AVAILABLE;
} }
$boardCode = (string) $roomXml->attributes()['vpcode']; $boardCode = $roomRow['boardCode'];
$room = [ $room = [
'pid' => $this->pid, 'pid' => $this->pid,
'date' => 0, 'date' => 0,
'hotel' => $hotelUid, 'hotel' => $hotelUid,
'bus_pro_id' => (int) $roomXml->attributes()['idbuspro_zimmer'], 'bus_pro_id' => (int) $roomRow['busProId'],
'code' => $code, 'code' => $code,
'type' => $roomType, 'type' => $roomType,
'name' => (string) $roomXml->attributes()['zimmertext'], 'name' => $label,
'pax' => $pax, 'pax' => $pax,
'nights' => (int) $roomXml->attributes()['naechte'], 'nights' => (int) $roomRow['nights'],
'price' => $price, 'price' => $price,
'available' => $available * $pax, 'available' => $available * $pax,
'status' => $status, 'status' => $status,
@@ -680,45 +674,43 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
/** /**
* @param array $services * @param array $services
* @return int * @return float
*/ */
protected function determineDiscount(array $services): int protected function determineDiscount(array $services): float
{ {
foreach ($services as $key => $section) { foreach ($services as $key => $section) {
foreach ($section as $service) { foreach ($section as $service) {
if ($service['price'] < 0) { $price = $this->parseFormattedPrice($service['price']);
return (int) $service['price']; if ($price < 0) {
return $price;
} }
} }
} }
return 0; return 0.0;
} }
/** /**
* @param \SimpleXMLElement $xmlData * @param array[] $services a service list of a row
* @param string $serviceLabel
* @param bool $skipWithoutPrice * @param bool $skipWithoutPrice
* @return array * @return array
*/ */
protected function parseService(\SimpleXMLElement $xmlData, string $serviceLabel, bool $skipWithoutPrice = false): array protected function parseService(array $services, bool $skipWithoutPrice = false): array
{ {
$data = []; $data = [];
$services = $xmlData->xpath($serviceLabel . '/leistung');
foreach ($services as $service) { foreach ($services as $service) {
// Skip transportation service direction 'back' // Skip transportation service direction 'back'
if ($service->richtung && (string) $service->richtung === 'RUECK') { if ($service['direction'] === 'RUECK') {
continue; continue;
} }
// Skip services with status 'Buchungsstop' // Skip services with status 'Buchungsstop'
$status = (string) $service->status; if ($service['status'] === static::STATUS_STOP) {
if ($status === 'Buchungsstop') {
continue; continue;
} }
// Skip services of subcategory 'EIN' // Skip services of subcategory 'EIN'
$type = (string) $service->attributes()['unterart']; $type = $service['subType'];
if ($type === 'EIN') { if ($type === 'EIN') {
continue; continue;
} }
@@ -728,13 +720,14 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$type = 'VER'; $type = 'VER';
} }
$price = (float) str_replace(',', '.', $service->preis); // A service without a stated price has always been shown as included.
$price = $service['price'] === null ? 0.0 : (float) $service['price'];
if ($skipWithoutPrice && $price === 0.0) { if ($skipWithoutPrice && $price === 0.0) {
continue; continue;
} }
$label = (string) $service->text; $label = $service['label'];
if ($type === 'BUS') { if ($type === 'BUS') {
$label = static::LABEL_BUS; $label = static::LABEL_BUS;
} elseif ($type === 'BAH') { } elseif ($type === 'BAH') {
@@ -745,8 +738,8 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
$data[$type] = []; $data[$type] = [];
} }
$data[$type][(int)$service->attributes()['id']] = [ $data[$type][(int) $service['busProId']] = [
'idBusPro' => (int) $service->attributes()['idbuspro'], 'idBusPro' => (int) $service['busProId'],
'label' => $label, 'label' => $label,
'price' => number_format($price, 2, ',', '.'), 'price' => number_format($price, 2, ',', '.'),
]; ];
@@ -758,21 +751,17 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
/** /**
* @param \SimpleXMLElement $xmlDate * @param array[] $selectionGroups
* @param string $label * @param int $busProId
* @return array * @return array
*/ */
protected function parseSelectionGroup(\SimpleXMLElement $xmlDate, string $label): array protected function parseSelectionGroup(array $selectionGroups, int $busProId): array
{ {
$data = []; $data = [];
$selectionGroup = $xmlDate->xpath('selektiongruppe[@bezeichnung="' . $label . '"]/selektion'); foreach ($selectionGroups as $group) {
foreach ($selectionGroup as $selection) { if ((int) $group['busProId'] === $busProId) {
$attributes = $selection->attributes(); $this->addSelections($group['selections'], $data);
if (null === $attributes) {
continue;
} }
$id = (int) $attributes['idbuspro'];
$data[$id] = (string) $attributes['bezeichnung'];
} }
ksort($data); ksort($data);
@@ -780,6 +769,83 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
return $data; return $data;
} }
/**
* @param array[] $selectionGroups
* @return array
*/
protected function parsePersonnelSelectionGroup(array $selectionGroups): array
{
$data = [];
foreach ($selectionGroups as $group) {
if ($group['label'] !== static::SELECTION_GROUP_LABEL_PERSONNEL) {
continue;
}
$this->logger->warning(
'selection group matched by label, register its id',
['label' => $group['label'], 'idbuspro' => (int) $group['busProId']]
);
$this->addSelections($group['selections'], $data);
}
ksort($data);
return $data;
}
/**
* 'Gruppenkunde' and 'Endkunde' decide. A date carrying neither is judged by
* its travel code.
*
* @param array $row
* @return bool
*/
protected function isGroupTravel(array $row): bool
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup || $isEnd) {
return $isGroup;
}
return !preg_match(static::END_CUSTOMER_CODE_PATTERN, (string) $row['date']['code']);
}
/**
* Reports dates whose customer selection is missing or contradictory, along
* with what they import as.
*
* @param array $row
*/
protected function checkCustomerSelection(array $row): void
{
$customer = $this->parseSelectionGroup($row['selectionGroups'], static::SELECTION_GROUP_CUSTOMER);
$isGroup = isset($customer[static::SELECTION_CUSTOMER_GROUP]);
$isEnd = isset($customer[static::SELECTION_CUSTOMER_END]);
if ($isGroup !== $isEnd) {
return;
}
$this->logger->warning(
$isGroup
? 'date has both Gruppenkunde and Endkunde selected, importing as group travel'
: 'date has neither Gruppenkunde nor Endkunde selected, falling back to travel code, importing as '
. ($this->isGroupTravel($row) ? 'group travel' : 'end-customer travel'),
['product' => $row['product']['code'], 'date' => $row['date']['code']]
);
}
/**
* @param array[] $selections
* @param array $data
*/
protected function addSelections(array $selections, array &$data): void
{
foreach ($selections as $selection) {
$data[(int) $selection['busProId']] = $selection['label'];
}
}
/** /**
* @param array $services * @param array $services
* @param array $date * @param array $date
@@ -791,20 +857,42 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
foreach ($services['BUS'] as $service) { foreach ($services['BUS'] as $service) {
$price = $this->parseFormattedPrice($service['price']);
$date['bus_available'] = 1; $date['bus_available'] = 1;
$date['bus_price'] = (int) $service['price']; $date['bus_price'] = $price;
$date['bus_included'] = (int) ((int) $service['price'] === 0); $date['bus_included'] = (int) ($price === 0.0);
} }
} }
protected function getRoomMappings(): void /**
* A pickup time is the local date and time as BusPro states it
* ("2027-03-28T02:45:00"), taken over as is. An ISO-8601 time with an
* offset is still accepted and converted to Europe/Berlin, which cannot
* express a time inside the daylight saving gap.
*
* @param string $datetime
* @return string[] date as d.m.Y and time as H:i
*/
protected function parsePickupDateTime(string $datetime): array
{ {
$sql = 'SELECT code, type FROM tx_epproducts_domain_model_roommapping WHERE deleted = 0 AND hidden = 0'; if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/', $datetime, $parts)) {
$roomMappings = $this->db->fetchAllAssociative($sql); return [$parts[3] . '.' . $parts[2] . '.' . $parts[1], $parts[4] . ':' . $parts[5]];
foreach ($roomMappings as $mapping) {
$key = trim(mb_strtolower($mapping['code']));
$this->roomMappings[$key] = $mapping['type'];
} }
$pickupDateTime = (new \DateTimeImmutable($datetime))->setTimezone(new \DateTimeZone('Europe/Berlin'));
return [$pickupDateTime->format('d.m.Y'), $pickupDateTime->format('H:i')];
}
/**
* Reads back a price parseService() formatted for display ("1.234,50").
*
* @param string $price
* @return float
*/
protected function parseFormattedPrice(string $price): float
{
return (float) str_replace(['.', ','], ['', '.'], $price);
} }
protected function getHotelMappings(): void protected function getHotelMappings(): void
@@ -820,7 +908,9 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0 WHERE hotel.code != "" AND hotel.deleted = 0 AND hotel.hidden = 0
UNION SELECT matchcode.code code, matchcode.entity uid UNION SELECT matchcode.code code, matchcode.entity uid
FROM tx_epproducts_domain_model_matchcode matchcode FROM tx_epproducts_domain_model_matchcode matchcode
INNER JOIN tx_epproducts_domain_model_hotel matchcode_hotel ON matchcode.entity = matchcode_hotel.uid
WHERE matchcode.deleted = 0 AND matchcode.hidden = 0 WHERE matchcode.deleted = 0 AND matchcode.hidden = 0
AND matchcode_hotel.deleted = 0 AND matchcode_hotel.hidden = 0
ORDER BY code ORDER BY code
) u ) u
LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid LEFT JOIN tx_epproducts_domain_model_hotel h ON u.uid = h.uid
@@ -877,26 +967,4 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
} }
} }
protected function importPickups(): void
{
libxml_use_internal_errors (true);
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport/zustiege.xml');
if (!$xmlData = simplexml_load_string(file_get_contents($path))) {
libxml_clear_errors();
return;
}
foreach ($xmlData->zustieg as $entry) {
$attributes = $entry->attributes();
if (null === $attributes) {
continue;
}
$idBusPro = (int) $attributes['idbuspro'];
$this->pickups[$idBusPro] = [
'city' => (string) $entry->ort,
'street' => (string) $entry->strasse,
];
}
}
} }
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more