Compare commits
100
Commits
25789612bf
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f46d0acf | ||
|
|
4279669aa3 | ||
|
|
aa3d81102e | ||
|
|
739a4c49f7 | ||
|
|
88e0488b49 | ||
|
|
f57671b4d4 | ||
|
|
313fb151bc | ||
|
|
6350742084 | ||
|
|
ef7d81fe99 | ||
|
|
0477589a7a | ||
|
|
38962845d3 | ||
|
|
c7f43ad02c | ||
|
|
1111c180f8 | ||
|
|
69f9b42757 | ||
|
|
5bb4b6254a | ||
|
|
32b70f9b70 | ||
|
|
7839b97e63 | ||
|
|
416ac5c13d | ||
|
|
992ec19c33 | ||
|
|
57b09624f4 | ||
|
|
81724c818b | ||
|
|
b4a9156df6 | ||
|
|
41d188a8cc | ||
|
|
c6ab02305b | ||
|
|
306c6b809f | ||
|
|
4095c4a068 | ||
|
|
b41aeb7a25 | ||
|
|
c2dc88b8ab | ||
|
|
27832d6548 | ||
|
|
b510299032 | ||
|
|
1b6f480bee | ||
|
|
b9851943d0 | ||
|
|
d338529afc | ||
|
|
e3099bac5f | ||
|
|
322a59b31c | ||
|
|
e96dd0f06d | ||
|
|
f10d784a5c | ||
|
|
b5d0c484c0 | ||
|
|
d8bb376424 | ||
|
|
38d575cbe9 | ||
|
|
f9e8704046 | ||
|
|
49547ecda3 | ||
|
|
eb096bd8b5 | ||
|
|
b3541b21fb | ||
|
|
93ed5c6962 | ||
|
|
942724fdd1 | ||
|
|
e52f7dcff8 | ||
|
|
30e531497d | ||
|
|
2cb37e760a | ||
|
|
3ff3251419 | ||
|
|
abcd39107b | ||
|
|
599e3c7ee2 | ||
|
|
6ff308015a | ||
|
|
6e0479bf22 | ||
|
|
fb8fddf669 | ||
|
|
f745c7b683 | ||
|
|
9b3550ec40 | ||
|
|
d9943619f6 | ||
|
|
e33b57c9f1 | ||
|
|
dc594efee1 | ||
|
|
432d8da350 | ||
|
|
5b338eeab6 | ||
|
|
05ceae5100 | ||
|
|
f005272242 | ||
|
|
7d856d1031 | ||
|
|
f9e94231a7 | ||
|
|
0a3e8f59cb | ||
|
|
a01d1ba0c5 | ||
|
|
6828e49663 | ||
|
|
46001a02ed | ||
|
|
5e39e39b90 | ||
|
|
8dad782603 | ||
|
|
0e6a65dfa0 | ||
|
|
8f421da599 | ||
|
|
39fb85744d | ||
|
|
07b42c9334 | ||
|
|
e27b376285 | ||
|
|
20e56aab1f | ||
|
|
d7219ec341 | ||
|
|
0bdc6c873b | ||
|
|
b502127e2e | ||
|
|
baf7b5d94a | ||
|
|
87e6857bff | ||
|
|
47de6027c8 | ||
|
|
3178eee8a0 | ||
|
|
229c675e4d | ||
|
|
22072b4a25 | ||
|
|
d809d747b7 | ||
|
|
9fc490fabf | ||
|
|
5396ab941b | ||
|
|
e8154ebba1 | ||
|
|
7342db4afa | ||
|
|
b19d09ef64 | ||
|
|
8578f7fca4 | ||
|
|
843b963195 | ||
|
|
61b5e1a2d3 | ||
|
|
83b3cbea4d | ||
|
|
11fda126a8 | ||
|
|
1fcca90969 | ||
|
|
2f58b02928 |
No files matched your search
@@ -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
|
||||||
Executable
+124
@@ -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'"
|
||||||
Executable
+13
@@ -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
|
||||||
Executable
+12
@@ -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
|
||||||
+79
-42
@@ -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,22 +227,25 @@ 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:
|
||||||
# web_environment:
|
# web_environment:
|
||||||
# - SOMEENV=somevalue
|
# - SOMEENV=somevalue
|
||||||
# - SOMEOTHERENV=someothervalue
|
# - SOMEOTHERENV=someothervalue
|
||||||
|
|
||||||
# no_project_mount: false
|
# no_project_mount: false
|
||||||
# (Experimental) If true, DDEV will not mount the project into the web container;
|
# (Experimental) If true, DDEV will not mount the project into the web container;
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,42 @@
|
|||||||
|
# Backchannel endpoints MyE&P reads hotel and product data from. Same environment files and key
|
||||||
|
# as content-feed.http: pick "local" and put cmsApiKey in http-client.private.env.json.
|
||||||
|
|
||||||
### GET product data for myep
|
### GET product data for myep
|
||||||
GET https://ep-reisen.ddev.site/api/product
|
GET {{host}}/api/product?product=3VIIN&hotel=VLTS
|
||||||
?product=SSTMR
|
X-Api-Key: {{cmsApiKey}}
|
||||||
&hotel=SSTGR
|
|
||||||
&key=abcabc123
|
> {%
|
||||||
|
client.test("200 with key", function () {
|
||||||
|
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||||
|
});
|
||||||
|
%}
|
||||||
|
|
||||||
### GET hotel data for myep
|
### GET hotel data for myep
|
||||||
GET https://ep-reisen.ddev.site/api/hotel
|
GET {{host}}/api/hotel?hotel=DPWGRP
|
||||||
?hotel=DPWGRP
|
X-Api-Key: {{cmsApiKey}}
|
||||||
&key=abcabc123
|
|
||||||
|
> {%
|
||||||
|
client.test("200 with key", function () {
|
||||||
|
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||||
|
});
|
||||||
|
%}
|
||||||
|
|
||||||
|
### Transition form: key as query parameter, as MyE&P sends it today (still accepted)
|
||||||
|
GET {{host}}/api/hotel?hotel=DPWGRP&key={{cmsApiKey}}
|
||||||
|
|
||||||
|
> {%
|
||||||
|
client.test("200 with query key", function () {
|
||||||
|
client.assert(response.status === 200, "expected 200, got " + response.status);
|
||||||
|
});
|
||||||
|
%}
|
||||||
|
|
||||||
|
### Any other value is refused now - this used to pass
|
||||||
|
GET {{host}}/api/hotel?hotel=DPWGRP&key=AbcAbc123
|
||||||
|
|
||||||
|
> {%
|
||||||
|
client.test("403 with the old dummy key", function () {
|
||||||
|
client.assert(response.status === 403, "expected 403, got " + response.status);
|
||||||
|
});
|
||||||
|
%}
|
||||||
|
|
||||||
###
|
###
|
||||||
+4
-1
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"local": {
|
||||||
|
"host": "https://ep-reisen.ddev.site"
|
||||||
|
},
|
||||||
|
"prod": {
|
||||||
|
"host": "https://www.ep-reisen.de"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1500
-1738
File diff suppressed because it is too large.
Load diff
+9
-23
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-10
@@ -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,7 +300,58 @@ AddDefaultCharset utf-8
|
|||||||
# Enable URL rewriting
|
# Enable URL rewriting
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
# Force SSL
|
# 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
|
||||||
RewriteCond %{HTTPS} off
|
RewriteCond %{HTTPS} off
|
||||||
RewriteCond %{HTTP:X-Forwarded-Proto} =http
|
RewriteCond %{HTTP:X-Forwarded-Proto} =http
|
||||||
RewriteRule (.*) https://%{HTTP_HOST}/$1 [R=301,L]
|
RewriteRule (.*) https://%{HTTP_HOST}/$1 [R=301,L]
|
||||||
@@ -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',
|
||||||
|
|||||||
+2
@@ -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],
|
||||||
|
|||||||
+4
-2
@@ -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);
|
||||||
|
|||||||
+17
-10
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ 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
|
||||||
{
|
{
|
||||||
@@ -53,91 +54,101 @@ class AjaxCalendarController extends ActionController
|
|||||||
public function priceCalendarAction(Hotel $hotel, int $months = 12): void
|
public function priceCalendarAction(Hotel $hotel, int $months = 12): void
|
||||||
{
|
{
|
||||||
$hotelCode = trim($hotel->getCode() ?? '');
|
$hotelCode = trim($hotel->getCode() ?? '');
|
||||||
|
$this->tagPageCacheForHotel($hotelCode);
|
||||||
|
|
||||||
$dateRange = DateUtility::getDateRange(null, null, $months);
|
$dateRange = DateUtility::getDateRange(null, null, $months);
|
||||||
|
$period = $dateRange['period'];
|
||||||
$today = new \DateTimeImmutable('today');
|
$today = new \DateTimeImmutable('today');
|
||||||
|
|
||||||
$priceDataByDate = [];
|
$priceDataByDate = [];
|
||||||
$error = false;
|
$error = false;
|
||||||
|
$errorMessage = '';
|
||||||
|
|
||||||
if ($hotelCode !== '') {
|
if ($hotelCode !== '') {
|
||||||
try {
|
try {
|
||||||
$cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [
|
// This action is cacheable, so the rendered markup is held in the page cache
|
||||||
$hotelCode,
|
// for config.cache_period. An additional application-level cache here would
|
||||||
$dateRange['period']->getStartDate()->format('Y-m-d'),
|
// key on exactly the same granularity (hotel + date range) and only stack a
|
||||||
$dateRange['period']->getEndDate()->format('Y-m-d'),
|
// second TTL on top, pushing worst-case staleness past the freshness budget.
|
||||||
]));
|
$rawData = $this->myEpClient->getPriceConfigData($hotelCode);
|
||||||
$cachedData = $this->cache->get($cacheIdentifier);
|
|
||||||
|
|
||||||
if (false !== $cachedData) {
|
$availableDates = [];
|
||||||
$priceDataByDate = $cachedData;
|
foreach ($rawData as $item) {
|
||||||
} else {
|
if (($item['status'] ?? '') === 'OK') {
|
||||||
$rawData = $this->myEpClient->getPriceConfigData(
|
$availableDates[$item['date']] = true;
|
||||||
$hotelCode,
|
}
|
||||||
$dateRange['period']->getStartDate(),
|
}
|
||||||
$dateRange['period']->getEndDate()
|
|
||||||
);
|
|
||||||
|
|
||||||
$availableDates = [];
|
// The API no longer honours a date-range constraint, so it may return data
|
||||||
foreach ($rawData as $item) {
|
// further out than the default $months grid covers. Grow the grid to match
|
||||||
if (($item['status'] ?? '') === 'OK') {
|
// whatever the API actually returned rather than silently dropping the tail.
|
||||||
$availableDates[$item['date']] = true;
|
$lastDataDate = null;
|
||||||
}
|
|
||||||
|
foreach ($rawData as $item) {
|
||||||
|
$date = $item['date'];
|
||||||
|
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||||
|
if ($dt < $today) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rawData as $item) {
|
if (null === $lastDataDate || $dt > $lastDataDate) {
|
||||||
$date = $item['date'];
|
$lastDataDate = $dt;
|
||||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
|
||||||
if ($dt < $today) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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,
|
|
||||||
'includedPax' => $item['includedPax'] ?? null,
|
|
||||||
'minNights' => (int)($item['minNights'] ?? 0),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL);
|
$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) {
|
} catch (ApiException $e) {
|
||||||
$error = true;
|
$error = true;
|
||||||
|
$errorMessage = $e->getMessage();
|
||||||
|
$this->disablePageCache();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$error = true;
|
$error = true;
|
||||||
|
$errorMessage = 'Missing hotel code';
|
||||||
|
$this->disablePageCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->view->assignMultiple([
|
$this->view->assignMultiple([
|
||||||
'priceDataByDate' => $priceDataByDate,
|
'priceDataByDate' => $priceDataByDate,
|
||||||
'calendarMonths' => $this->buildCalendarMonths($dateRange['period']),
|
'calendarMonths' => $this->buildCalendarMonths($period),
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
|
'errorMessage' => $errorMessage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function priceConfigAction(Hotel $hotel, string $year = null, string $month = null, int $months = 12)
|
public function priceConfigAction(Hotel $hotel): void
|
||||||
{
|
{
|
||||||
$dateRange = DateUtility::getDateRange($year, $month, $months);
|
|
||||||
|
|
||||||
$apiDateFrom = $dateRange['period']->getStartDate();
|
|
||||||
$apiDateTo = $dateRange['period']->getEndDate();
|
|
||||||
$hotelCode = trim($hotel->getCode() ?? '');
|
$hotelCode = trim($hotel->getCode() ?? '');
|
||||||
|
$this->tagPageCacheForHotel($hotelCode);
|
||||||
|
|
||||||
$priceConfig = [
|
$priceConfig = [
|
||||||
'data' => [],
|
'data' => [],
|
||||||
@@ -146,10 +157,11 @@ class AjaxCalendarController extends ActionController
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode, $apiDateFrom, $apiDateTo);
|
$priceConfig['data'] = $this->myEpClient->getPriceConfigData($hotelCode);
|
||||||
} catch (ApiException $e) {
|
} catch (ApiException $e) {
|
||||||
$priceConfig['success'] = false;
|
$priceConfig['success'] = false;
|
||||||
$priceConfig['error'] = $e->getMessage();
|
$priceConfig['error'] = $e->getMessage();
|
||||||
|
$this->disablePageCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->view->assign('priceConfig', $priceConfig);
|
$this->view->assign('priceConfig', $priceConfig);
|
||||||
@@ -159,6 +171,7 @@ class AjaxCalendarController extends ActionController
|
|||||||
{
|
{
|
||||||
$year = $year ?? (new \DateTimeImmutable())->format('Y');
|
$year = $year ?? (new \DateTimeImmutable())->format('Y');
|
||||||
$hotelCode = trim($hotel->getCode() ?? '');
|
$hotelCode = trim($hotel->getCode() ?? '');
|
||||||
|
$this->tagPageCacheForHotel($hotelCode);
|
||||||
|
|
||||||
$priceTable = [
|
$priceTable = [
|
||||||
'data' => [],
|
'data' => [],
|
||||||
@@ -171,6 +184,7 @@ class AjaxCalendarController extends ActionController
|
|||||||
} catch (ApiException $e) {
|
} catch (ApiException $e) {
|
||||||
$priceTable['success'] = false;
|
$priceTable['success'] = false;
|
||||||
$priceTable['error'] = $e->getMessage();
|
$priceTable['error'] = $e->getMessage();
|
||||||
|
$this->disablePageCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->view->assignMultiple([
|
$this->view->assignMultiple([
|
||||||
@@ -207,7 +221,7 @@ 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);
|
||||||
|
|
||||||
@@ -218,7 +232,7 @@ class AjaxCalendarController extends ActionController
|
|||||||
$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);
|
||||||
}
|
}
|
||||||
@@ -235,6 +249,43 @@ 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
|
private function buildTooltip(string $status, array $item): string
|
||||||
{
|
{
|
||||||
if ($status === 'blocked') {
|
if ($status === 'blocked') {
|
||||||
@@ -249,11 +300,12 @@ class AjaxCalendarController extends ActionController
|
|||||||
private function buildPriceTooltip(array $item): string
|
private function buildPriceTooltip(array $item): string
|
||||||
{
|
{
|
||||||
$price = $item['pricePerNight'] ?? null;
|
$price = $item['pricePerNight'] ?? null;
|
||||||
|
$currency = $this->getCurrencySymbol($item['currency'] ?? null);
|
||||||
|
|
||||||
if ($price !== null) {
|
if ($price !== null) {
|
||||||
$priceStr = (($item['type'] ?? null) === 'discount')
|
$priceStr = (($item['type'] ?? null) === 'discount')
|
||||||
? $price . ' € (Angebot)'
|
? $price . ' ' . $currency
|
||||||
: 'ab ' . $price . ' €';
|
: 'ab ' . $price . ' ' . $currency;
|
||||||
} else {
|
} else {
|
||||||
$priceStr = 'Anfrage';
|
$priceStr = 'Anfrage';
|
||||||
}
|
}
|
||||||
@@ -265,6 +317,15 @@ class AjaxCalendarController extends ActionController
|
|||||||
: $priceStr;
|
: $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,12 +87,16 @@ 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);
|
||||||
$this->request->setArgument('filterSettings', $filterSettings);
|
if (count($filterSettings) > 0) {
|
||||||
|
$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);
|
||||||
|
|||||||
+1
-1
@@ -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'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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;
|
||||||
@@ -285,6 +286,56 @@ class Hotel extends AbstractEntity implements TeaserInterface
|
|||||||
*/
|
*/
|
||||||
protected $mainSeasonTo;
|
protected $mainSeasonTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected $bedsCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected $parkingCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $altitude = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $shower = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $partyroom = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $gamesroom = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $sauna = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $wifi = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $distanceBus = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $distanceLift = '';
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->initStorageObjects();
|
$this->initStorageObjects();
|
||||||
@@ -1103,4 +1154,120 @@ class Hotel extends AbstractEntity implements TeaserInterface
|
|||||||
{
|
{
|
||||||
$this->mainSeasonTo = $mainSeasonTo;
|
$this->mainSeasonTo = $mainSeasonTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBedsCount()
|
||||||
|
{
|
||||||
|
return $this->bedsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBedsCount($bedsCount)
|
||||||
|
{
|
||||||
|
$this->bedsCount = $bedsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParkingCount()
|
||||||
|
{
|
||||||
|
return $this->parkingCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setParkingCount($parkingCount)
|
||||||
|
{
|
||||||
|
$this->parkingCount = $parkingCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAltitude()
|
||||||
|
{
|
||||||
|
return $this->altitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAltitude($altitude)
|
||||||
|
{
|
||||||
|
$this->altitude = $altitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getShower()
|
||||||
|
{
|
||||||
|
return $this->shower;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setShower($shower)
|
||||||
|
{
|
||||||
|
$this->shower = $shower;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPartyroom()
|
||||||
|
{
|
||||||
|
return $this->partyroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPartyroom($partyroom)
|
||||||
|
{
|
||||||
|
$this->partyroom = $partyroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isGamesroom()
|
||||||
|
{
|
||||||
|
return $this->gamesroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGamesroom($gamesroom)
|
||||||
|
{
|
||||||
|
$this->gamesroom = $gamesroom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSauna()
|
||||||
|
{
|
||||||
|
return $this->sauna;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSauna($sauna)
|
||||||
|
{
|
||||||
|
$this->sauna = $sauna;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isWifi()
|
||||||
|
{
|
||||||
|
return $this->wifi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWifi($wifi)
|
||||||
|
{
|
||||||
|
$this->wifi = $wifi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistanceBus()
|
||||||
|
{
|
||||||
|
return $this->distanceBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDistanceBus($distanceBus)
|
||||||
|
{
|
||||||
|
$this->distanceBus = $distanceBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistanceLift()
|
||||||
|
{
|
||||||
|
return $this->distanceLift;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDistanceLift($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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\Middleware;
|
||||||
|
|
||||||
|
use EP\EpProducts\ContentFeed\ContentFeedApi;
|
||||||
|
use EP\EpProducts\Http\ApiKeyAuthenticator;
|
||||||
|
use EP\EpProducts\Service\MyEpImageService;
|
||||||
|
use EP\EpProducts\Utility\HotelIconUtility;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||||
|
use TYPO3\CMS\Core\Resource\FileRepository;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||||
|
|
||||||
|
class ApiMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var MyEpImageService
|
||||||
|
*/
|
||||||
|
private $hotelImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var MyEpImageService
|
||||||
|
*/
|
||||||
|
private $productImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var MyEpImageService
|
||||||
|
*/
|
||||||
|
private $regionImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var FileRepository
|
||||||
|
*/
|
||||||
|
private $fileRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImageService
|
||||||
|
*/
|
||||||
|
private $imageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ContentFeedApi
|
||||||
|
*/
|
||||||
|
private $contentFeedApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ApiKeyAuthenticator
|
||||||
|
*/
|
||||||
|
private $authenticator;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
MyEpImageService $hotelImageService,
|
||||||
|
MyEpImageService $productImageService,
|
||||||
|
MyEpImageService $regionImageService,
|
||||||
|
FileRepository $fileRepository,
|
||||||
|
ImageService $imageService,
|
||||||
|
ContentFeedApi $contentFeedApi,
|
||||||
|
ApiKeyAuthenticator $authenticator
|
||||||
|
) {
|
||||||
|
$this->hotelImageService = $hotelImageService;
|
||||||
|
$this->productImageService = $productImageService;
|
||||||
|
$this->regionImageService = $regionImageService;
|
||||||
|
$this->fileRepository = $fileRepository;
|
||||||
|
$this->imageService = $imageService;
|
||||||
|
$this->contentFeedApi = $contentFeedApi;
|
||||||
|
$this->authenticator = $authenticator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$path = $request->getUri()->getPath();
|
||||||
|
|
||||||
|
if ('/api/hotel' === $path) {
|
||||||
|
return $this->handleHotelRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('/api/product' === $path) {
|
||||||
|
return $this->handleProductRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
|
||||||
|
return $this->contentFeedApi->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
if (!$this->authenticator->isAuthorized($request)) {
|
||||||
|
return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hotelCode = $request->getQueryParams()['hotel'] ?? null;
|
||||||
|
|
||||||
|
if (null === $hotelCode) {
|
||||||
|
return new JsonResponse(['message' => 'No hotel code provided'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->fetchHotelData($hotelCode);
|
||||||
|
|
||||||
|
if ([] === $data) {
|
||||||
|
return new JsonResponse(['message' => 'Hotel not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
if (!$this->authenticator->isAuthorized($request)) {
|
||||||
|
return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$productCode = $request->getQueryParams()['product'] ?? null;
|
||||||
|
$hotelCode = $request->getQueryParams()['hotel'] ?? null;
|
||||||
|
|
||||||
|
if (null === $productCode) {
|
||||||
|
return new JsonResponse(['message' => 'No product code provided'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->fetchProductData($productCode);
|
||||||
|
|
||||||
|
if (null === $data) {
|
||||||
|
return new JsonResponse(['message' => 'Product not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $hotelCode) {
|
||||||
|
$data['hotel'] = $this->fetchHotelData($hotelCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchProductData(string $productCode): ?array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_epproducts_domain_model_product');
|
||||||
|
|
||||||
|
$data = $queryBuilder
|
||||||
|
->select(
|
||||||
|
'product.uid as productUid',
|
||||||
|
'product.name as productName',
|
||||||
|
'product.code as productCode',
|
||||||
|
'product.region as regionUid',
|
||||||
|
'country.name as countryName',
|
||||||
|
'city.name as cityName',
|
||||||
|
)
|
||||||
|
->from('tx_epproducts_domain_model_product', 'product')
|
||||||
|
->leftJoin('product', 'tx_epproducts_domain_model_country', 'country', 'product.country = country.uid')
|
||||||
|
->leftJoin('product', 'tx_epproducts_domain_model_city', 'city', 'product.city = city.uid')
|
||||||
|
->leftJoin('product', 'tx_epproducts_domain_model_matchcode', 'matchcode', 'product.uid = matchcode.entity AND matchcode.entity_type="hotel"')
|
||||||
|
->where($queryBuilder->expr()->eq('product.code', $queryBuilder->createNamedParameter($productCode)))
|
||||||
|
->orWhere($queryBuilder->expr()->eq('matchcode.code', $queryBuilder->createNamedParameter($productCode)))
|
||||||
|
->setMaxResults(1)
|
||||||
|
->execute()
|
||||||
|
->fetchAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
if (false === $data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $this->serializeImages($this->productImageService->getImages((int) $data['productUid']));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product' => [
|
||||||
|
'name' => $data['productName'],
|
||||||
|
'code' => $data['productCode'],
|
||||||
|
'images' => $images,
|
||||||
|
],
|
||||||
|
'country' => [
|
||||||
|
'name' => $data['countryName'],
|
||||||
|
],
|
||||||
|
'region' => $this->fetchRegionData((int) $data['regionUid']),
|
||||||
|
'city' => [
|
||||||
|
'name' => $data['cityName'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchHotelData(string $hotelCode): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_epproducts_domain_model_hotel');
|
||||||
|
|
||||||
|
$hotel = $queryBuilder
|
||||||
|
->select(
|
||||||
|
'hotel.uid',
|
||||||
|
'hotel.name',
|
||||||
|
'hotel.code',
|
||||||
|
'hotel.description',
|
||||||
|
'hotel.features',
|
||||||
|
'hotel.room_types',
|
||||||
|
'hotel.additional_information',
|
||||||
|
'hotel.address',
|
||||||
|
'hotel.beds_count',
|
||||||
|
'hotel.parking_count',
|
||||||
|
'hotel.altitude',
|
||||||
|
'hotel.shower',
|
||||||
|
'hotel.partyroom',
|
||||||
|
'hotel.gamesroom',
|
||||||
|
'hotel.sauna',
|
||||||
|
'hotel.wifi',
|
||||||
|
'hotel.distance_bus',
|
||||||
|
'hotel.distance_lift',
|
||||||
|
'hotel.region',
|
||||||
|
)
|
||||||
|
->from('tx_epproducts_domain_model_hotel', 'hotel')
|
||||||
|
->leftJoin('hotel', 'tx_epproducts_domain_model_matchcode', 'alias', 'hotel.uid = alias.entity AND alias.entity_type="hotel"')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->orX(
|
||||||
|
$queryBuilder->expr()->eq('hotel.code', $queryBuilder->createNamedParameter($hotelCode)),
|
||||||
|
$queryBuilder->expr()->eq('alias.code', $queryBuilder->createNamedParameter($hotelCode)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->execute()
|
||||||
|
->fetchAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
if (false === $hotel) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $this->serializeImages($this->hotelImageService->getImages((int) $hotel['uid']));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $hotel['name'],
|
||||||
|
'code' => $hotel['code'],
|
||||||
|
'description' => $hotel['description'],
|
||||||
|
'features' => $hotel['features'],
|
||||||
|
'room_types' => $hotel['room_types'],
|
||||||
|
'additional_information' => $hotel['additional_information'],
|
||||||
|
'address' => $hotel['address'],
|
||||||
|
'images' => $images,
|
||||||
|
'icons' => HotelIconUtility::getIcons($hotel),
|
||||||
|
'region' => $this->fetchRegionData((int) $hotel['region']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchRegionData(int $regionUid): array
|
||||||
|
{
|
||||||
|
if (0 === $regionUid) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_epproducts_domain_model_region');
|
||||||
|
|
||||||
|
$region = $queryBuilder
|
||||||
|
->select(
|
||||||
|
'region.uid',
|
||||||
|
'region.name',
|
||||||
|
'region.latitude',
|
||||||
|
'region.longitude',
|
||||||
|
'region.ski_area',
|
||||||
|
'region.ski_area_extended',
|
||||||
|
'region.description',
|
||||||
|
'region.news',
|
||||||
|
'region.length',
|
||||||
|
'region.height',
|
||||||
|
'region.lifts',
|
||||||
|
'webcam.url as webcamUrl',
|
||||||
|
)
|
||||||
|
->from('tx_epproducts_domain_model_region', 'region')
|
||||||
|
->leftJoin('region', 'tx_epproducts_domain_model_webcam', 'webcam', 'region.webcam = webcam.uid')
|
||||||
|
->where($queryBuilder->expr()->eq('region.uid', $queryBuilder->createNamedParameter($regionUid, \PDO::PARAM_INT)))
|
||||||
|
->setMaxResults(1)
|
||||||
|
->execute()
|
||||||
|
->fetchAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
if (false === $region) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $this->serializeImages($this->regionImageService->getImages((int) $region['uid']));
|
||||||
|
$regionMaps = $this->serializeFileReferences(
|
||||||
|
$this->fileRepository->findByRelation('tx_epproducts_domain_model_region', 'region_maps', (int) $region['uid'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $region['name'],
|
||||||
|
'latitude' => $region['latitude'],
|
||||||
|
'longitude' => $region['longitude'],
|
||||||
|
'webcam' => $region['webcamUrl'],
|
||||||
|
'ski_area' => $region['ski_area'],
|
||||||
|
'ski_area_extended' => $region['ski_area_extended'],
|
||||||
|
'description' => $region['description'],
|
||||||
|
'news' => $region['news'],
|
||||||
|
'length' => $region['length'],
|
||||||
|
'altitude' => $region['height'],
|
||||||
|
'lifts' => $region['lifts'],
|
||||||
|
'images' => $images,
|
||||||
|
'region_maps' => $regionMaps,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 'original' entry of AbstractImageService::getImages() holds raw FileReference objects,
|
||||||
|
* which are needed by Fluid templates elsewhere but serialize to empty objects in JSON responses.
|
||||||
|
* We replace it with the same {url, alt, title} shape used for 'region_maps'.
|
||||||
|
*/
|
||||||
|
private function serializeImages(array $images): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'original' => $this->serializeFileReferences($images['original']),
|
||||||
|
'resized' => $images['resized'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeFileReferences(array $files): array
|
||||||
|
{
|
||||||
|
return array_map(function ($file) {
|
||||||
|
return [
|
||||||
|
'url' => $this->imageService->getImageUri($file, true),
|
||||||
|
'alt' => $file->getProperty('alternative'),
|
||||||
|
'title' => $file->getProperty('title'),
|
||||||
|
];
|
||||||
|
}, $files);
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\Middleware;
|
||||||
|
|
||||||
|
use EP\EpProducts\ContentFeed\ContentFeedApi;
|
||||||
|
use EP\EpProducts\ContentFeed\ContentFeedConfiguration;
|
||||||
|
use EP\EpProducts\ContentFeed\ManifestBuilder;
|
||||||
|
use EP\EpProducts\Http\ApiKeyAuthenticator;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Frontend\Controller\ErrorController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the chatbot content-only page type (an unstyled copy of every page) off the public web:
|
||||||
|
* only requests carrying the CMS API key get it.
|
||||||
|
*
|
||||||
|
* Runs once the page type is known and before TSFE, so a page-cache hit cannot bypass it.
|
||||||
|
* staticfilecache never stores the type (`tx_staticfilecache.disableCache`); if it did, .htaccess
|
||||||
|
* would serve the file without this check ever running.
|
||||||
|
*/
|
||||||
|
class ContentPageTypeGuardMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var ApiKeyAuthenticator
|
||||||
|
*/
|
||||||
|
private $authenticator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ContentFeedConfiguration
|
||||||
|
*/
|
||||||
|
private $configuration;
|
||||||
|
|
||||||
|
public function __construct(ApiKeyAuthenticator $authenticator, ContentFeedConfiguration $configuration)
|
||||||
|
{
|
||||||
|
$this->authenticator = $authenticator;
|
||||||
|
$this->configuration = $configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$routing = $request->getAttribute('routing');
|
||||||
|
|
||||||
|
if (!$routing instanceof PageArguments
|
||||||
|
|| (string) ManifestBuilder::CONTENT_PAGE_TYPE !== $routing->getPageType()
|
||||||
|
) {
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->authenticator->isAuthorized($request)) {
|
||||||
|
// 404, not 403: an unauthenticated caller should not learn the type exists.
|
||||||
|
return GeneralUtility::makeInstance(ErrorController::class)
|
||||||
|
->pageNotFoundAction($request, 'The requested page does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->configuration->isEnabled()) {
|
||||||
|
return ContentFeedApi::disabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace EP\EpProducts\Middleware;
|
|
||||||
|
|
||||||
use EP\EpProducts\Service\MyEpImageService;
|
|
||||||
use EP\EpProducts\Service\ProductImageService;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
||||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
|
||||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
||||||
|
|
||||||
class ProductApiMiddleware implements MiddlewareInterface
|
|
||||||
{
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
|
||||||
{
|
|
||||||
$path = $request->getUri()->getPath();
|
|
||||||
|
|
||||||
if ('/api/hotel' === $path) {
|
|
||||||
return $this->handleHotelRequest($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('/api/product' === $path) {
|
|
||||||
return $this->handleProductRequest($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $handler->handle($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleHotelRequest(ServerRequestInterface $request): ResponseInterface
|
|
||||||
{
|
|
||||||
// This is currently fake, we only check for existence. Since we are communicating
|
|
||||||
// on the backchannel only it's not much of a problem.
|
|
||||||
$apiKey = $request->getQueryParams()['key'] ?? null;
|
|
||||||
|
|
||||||
if (null === $apiKey) {
|
|
||||||
return new JsonResponse(['message' => 'No api key provided'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$hotelCode = $request->getQueryParams()['hotel'] ?? null;
|
|
||||||
|
|
||||||
if (null === $hotelCode) {
|
|
||||||
return new JsonResponse(['message' => 'No hotel code provided'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $this->fetchHotelData($hotelCode);
|
|
||||||
|
|
||||||
if ([] === $data) {
|
|
||||||
return new JsonResponse(['message' => 'Hotel not found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse($data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleProductRequest(ServerRequestInterface $request): ResponseInterface
|
|
||||||
{
|
|
||||||
// This is currently fake, we only check for existence. Since we are communicating
|
|
||||||
// on the backchannel only it's not much of a problem.
|
|
||||||
$apiKey = $request->getQueryParams()['key'] ?? null;
|
|
||||||
|
|
||||||
if (null === $apiKey) {
|
|
||||||
return new JsonResponse(['message' => 'No api key provided'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$productCode = $request->getQueryParams()['product'] ?? null;
|
|
||||||
$hotelCode = $request->getQueryParams()['hotel'] ?? null;
|
|
||||||
|
|
||||||
if (null === $productCode) {
|
|
||||||
return new JsonResponse(['message' => 'No product code provided'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $this->fetchProductData($productCode);
|
|
||||||
|
|
||||||
if (null === $data) {
|
|
||||||
return new JsonResponse(['message' => 'Product not found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $hotelCode) {
|
|
||||||
$data['hotel'] = $this->fetchHotelData($hotelCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse($data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fetchProductData(string $productCode): ?array
|
|
||||||
{
|
|
||||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
||||||
->getQueryBuilderForTable('tx_epproducts_domain_model_product');
|
|
||||||
|
|
||||||
$data = $queryBuilder
|
|
||||||
->select(
|
|
||||||
'product.name as productName',
|
|
||||||
'product.code as productCode',
|
|
||||||
'country.name as countryName',
|
|
||||||
'region.name as regionName',
|
|
||||||
'city.name as cityName',
|
|
||||||
)
|
|
||||||
->from('tx_epproducts_domain_model_product', 'product')
|
|
||||||
->leftJoin('product', 'tx_epproducts_domain_model_country', 'country', 'product.country = country.uid')
|
|
||||||
->leftJoin('product', 'tx_epproducts_domain_model_region', 'region', 'product.region = region.uid')
|
|
||||||
->leftJoin('product', 'tx_epproducts_domain_model_city', 'city', 'product.city = city.uid')
|
|
||||||
->leftJoin('product', 'tx_epproducts_domain_model_matchcode', 'matchcode', 'product.uid = matchcode.entity AND matchcode.entity_type="hotel"')
|
|
||||||
->where($queryBuilder->expr()->eq('product.code', $queryBuilder->createNamedParameter($productCode)))
|
|
||||||
->orWhere($queryBuilder->expr()->eq('matchcode.code', $queryBuilder->createNamedParameter($productCode)))
|
|
||||||
->setMaxResults(1)
|
|
||||||
->execute()
|
|
||||||
->fetchAssociative()
|
|
||||||
;
|
|
||||||
|
|
||||||
if (false === $data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$images = GeneralUtility::makeInstance(ProductImageService::class)
|
|
||||||
->getImages((int) $data['uid']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'product' => [
|
|
||||||
'name' => $data['productName'],
|
|
||||||
'code' => $data['productCode'],
|
|
||||||
'images' => $images,
|
|
||||||
],
|
|
||||||
'country' => [
|
|
||||||
'name' => $data['countryName'],
|
|
||||||
],
|
|
||||||
'region' => [
|
|
||||||
'name' => $data['regionName'],
|
|
||||||
],
|
|
||||||
'city' => [
|
|
||||||
'name' => $data['cityName'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fetchHotelData(string $hotelCode): array
|
|
||||||
{
|
|
||||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
||||||
->getQueryBuilderForTable('tx_epproducts_domain_model_hotel');
|
|
||||||
|
|
||||||
$hotel = $queryBuilder
|
|
||||||
->select(
|
|
||||||
'hotel.uid',
|
|
||||||
'hotel.name',
|
|
||||||
'hotel.code',
|
|
||||||
'hotel.description',
|
|
||||||
'hotel.features',
|
|
||||||
'hotel.room_types',
|
|
||||||
'hotel.additional_information',
|
|
||||||
'hotel.address',
|
|
||||||
)
|
|
||||||
->from('tx_epproducts_domain_model_hotel', 'hotel')
|
|
||||||
->leftJoin('hotel', 'tx_epproducts_domain_model_matchcode', 'alias', 'hotel.uid = alias.entity AND alias.entity_type="hotel"')
|
|
||||||
->where(
|
|
||||||
$queryBuilder->expr()->orX(
|
|
||||||
$queryBuilder->expr()->eq('hotel.code', $queryBuilder->createNamedParameter($hotelCode)),
|
|
||||||
$queryBuilder->expr()->eq('alias.code', $queryBuilder->createNamedParameter($hotelCode)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
->setMaxResults(1)
|
|
||||||
->execute()
|
|
||||||
->fetchAssociative()
|
|
||||||
;
|
|
||||||
|
|
||||||
if (false === $hotel) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$images = GeneralUtility::makeInstance(MyEpImageService::class)
|
|
||||||
->getImages((int) $hotel['uid']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => $hotel['name'],
|
|
||||||
'code' => $hotel['code'],
|
|
||||||
'description' => $hotel['description'],
|
|
||||||
'features' => $hotel['features'],
|
|
||||||
'roomTypes' => $hotel['room_types'],
|
|
||||||
'additionalInformation' => $hotel['additional_information'],
|
|
||||||
'address' => $hotel['address'],
|
|
||||||
'images' => $images,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,13 +178,11 @@ class ApiClient implements LoggerAwareInterface
|
|||||||
/**
|
/**
|
||||||
* @throws ApiException
|
* @throws ApiException
|
||||||
*/
|
*/
|
||||||
public function getPriceConfigData(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
public function getPriceConfigData(string $hotelCode): array
|
||||||
{
|
{
|
||||||
return $this->requestJson('GET', 'contingents/calendar', [
|
return $this->requestJson('GET', 'contingents/calendar', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'hotelCode' => $hotelCode,
|
'hotelCode' => $hotelCode,
|
||||||
'dateFrom' => $dateFrom->format('Y-m-d'),
|
|
||||||
'dateTo' => $dateTo->format('Y-m-d'),
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -155,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) {
|
||||||
@@ -171,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;
|
||||||
@@ -189,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
|
||||||
*/
|
*/
|
||||||
@@ -209,14 +322,58 @@ class ApiClient implements LoggerAwareInterface
|
|||||||
|
|
||||||
private function getHttpClient(): HttpClientInterface
|
private function getHttpClient(): HttpClientInterface
|
||||||
{
|
{
|
||||||
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)
|
if (null !== static::$httpClient) {
|
||||||
->get('ep_products');
|
return static::$httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
static::$accessToken = $this->getAccessToken($config);
|
$config = $this->getConfiguration();
|
||||||
|
|
||||||
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [
|
$options = [];
|
||||||
'auth_bearer' => static::$accessToken->getToken(),
|
|
||||||
]);
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,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]);
|
||||||
@@ -247,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user