Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f8baba06 | ||
|
|
3ffa80e2bc | ||
|
|
ab1a21dffc | ||
|
|
bccd8c6fc3 | ||
|
|
2ea298854d | ||
|
|
b7e4f29813 | ||
|
|
7780646601 | ||
|
|
0f58f07757 | ||
|
|
ef263e064a | ||
|
|
7127b4d050 | ||
|
|
42744fa30f | ||
|
|
be3f6e6288 | ||
|
|
9291adfdff | ||
|
|
c9426eff3b | ||
|
|
cd350c212e | ||
|
|
1f1ac1a0cc | ||
|
|
2dd5b7ad64 | ||
|
|
62ac8038dc | ||
|
|
5f94f0487b | ||
|
|
64880355e8 | ||
|
|
1dc8489239 | ||
|
|
5831294d33 | ||
|
|
073bbdc0f8 | ||
|
|
ef75383885 | ||
|
|
bfb25a4051 | ||
|
|
f55febd545 | ||
|
|
d27b78cec1 | ||
|
|
1bb145e3be | ||
|
|
59b9a42009 | ||
|
|
a576fa2d23 | ||
|
|
14fe9bd44f | ||
|
|
cce59b8ed6 | ||
|
|
8904e1c7fe | ||
|
|
a0f763af63 | ||
|
|
667c9d4677 | ||
|
|
1622f2be6a | ||
|
|
28914d6586 | ||
|
|
cfc3957fec | ||
|
|
aee2eb848d | ||
|
|
42f17db542 | ||
|
|
6315fbd0f7 | ||
|
|
79e5a3c12d | ||
|
|
e9c1dd8b9d | ||
|
|
b1ff6989ec | ||
|
|
1d97a19982 | ||
|
|
c3519124b7 | ||
|
|
05c769bab2 | ||
|
|
c1539a47f9 | ||
|
|
f54f66cd32 | ||
|
|
c8a33cdf43 | ||
|
|
b4c03160ca | ||
|
|
72cd36a573 | ||
|
|
4d3a4b7c04 | ||
|
|
b9dfeb2d3e | ||
|
|
e42ef2d3c9 | ||
|
|
bb69a28ec2 | ||
|
|
e8c2261b7e | ||
|
|
7665385cbf | ||
|
|
346dee23e4 | ||
|
|
109f36e7f5 | ||
|
|
0e9c5e6e1a | ||
|
|
145a08e511 | ||
|
|
2b8046b416 | ||
|
|
e27b5da3c8 | ||
|
|
613e0b9a5c | ||
|
|
0564e3c424 | ||
|
|
32c73ab7b1 | ||
|
|
420efb25ad | ||
|
|
150998e120 | ||
|
|
31f414b4d8 | ||
|
|
9c6860ecc6 | ||
|
|
6b32b6baa4 | ||
|
|
3527be9c29 | ||
|
|
0239aee89a | ||
|
|
f21f4a0385 | ||
|
|
fa2b5e0d7f | ||
|
|
1ac7720765 | ||
|
|
4182c30b72 | ||
|
|
e4aad8a456 | ||
|
|
d78638e6e5 | ||
|
|
1726523eaf | ||
|
|
1aa564960d | ||
|
|
cd2765bab5 | ||
|
|
390ee81675 | ||
|
|
6dbbfd87f8 | ||
|
|
118f86b2a0 | ||
|
|
27d77624da | ||
|
|
20a67e9b96 | ||
|
|
233748a6c5 | ||
|
|
67491a7f68 | ||
|
|
c7129d5791 | ||
|
|
4abee677f6 | ||
|
|
21600dc2d6 | ||
|
|
f0b84c9943 | ||
|
|
59418adc02 | ||
|
|
de52907eec | ||
|
|
bfb1a04ea0 | ||
|
|
b206c3990b | ||
|
|
54a37c5bf7 | ||
|
|
fa98ceac29 | ||
|
|
1bab230f10 | ||
|
|
5b55d0984a | ||
|
|
2b3a8f1c69 | ||
|
|
696800aafd | ||
|
|
e7997f84eb | ||
|
|
8d3a7b99b4 | ||
|
|
61a8afdde7 | ||
|
|
1f3e79d57c | ||
|
|
f4691ec67e | ||
|
|
32bef9a4e9 | ||
|
|
30704f936a | ||
|
|
9167c84b4c | ||
|
|
f5f5d43ab5 | ||
|
|
9b2ad3845d | ||
|
|
e2513d6245 | ||
|
|
126fce9687 | ||
|
|
c1364a458b | ||
|
|
ecc4741f56 | ||
|
|
f33e55f201 | ||
|
|
ce1b9ece09 | ||
|
|
9d28d2f102 | ||
|
|
aaa7e9f4c3 | ||
|
|
26681e5c62 | ||
|
|
33c3dc6022 | ||
|
|
a6b0051f1b | ||
|
|
d0d1edb3ae | ||
|
|
6e96f6fb44 | ||
|
|
c6b28e7f4e | ||
|
|
3ce4d81a4c | ||
|
|
d5c572c25b | ||
|
|
bcded403be | ||
|
|
aeb25f3b7e | ||
|
|
421851cc4c | ||
|
|
66eb392151 | ||
|
|
beaccd93e0 | ||
|
|
111cda95f3 | ||
|
|
84ed41f325 | ||
|
|
e7cf172b9f | ||
|
|
e52cb98163 | ||
|
|
d85e81e8b9 | ||
|
|
1e5fe23159 | ||
|
|
2f78ddb8ad | ||
|
|
f19762d686 | ||
|
|
eba2f8e503 | ||
|
|
0a0bef9e82 | ||
|
|
29f415a91f | ||
|
|
a619add4e3 | ||
|
|
34a8d42fc3 | ||
|
|
cfd346d5df | ||
|
|
3d694ed7eb | ||
|
|
5021f414d1 | ||
|
|
51dbf20118 | ||
|
|
a50bd205cd | ||
|
|
da7b4dfb48 | ||
|
|
eee7be0c1e | ||
|
|
d55a201681 | ||
|
|
ba0eab9e96 | ||
|
|
b63804df88 | ||
|
|
647a25a78f | ||
|
|
6b0dfb3426 | ||
|
|
c8bb051ae6 | ||
|
|
b326510b04 | ||
|
|
293574187f | ||
|
|
b74f84e8ad | ||
|
|
19a272db96 | ||
|
|
67843c2f2e | ||
|
|
a01e966d6d | ||
|
|
9140b97a71 | ||
|
|
405f03ee7a | ||
|
|
b88ad24c3d | ||
|
|
bb630da5ed | ||
|
|
39d944351c | ||
|
|
2b272ef789 | ||
|
|
3e54a58b9b | ||
|
|
60176d19f4 | ||
|
|
9c716477aa | ||
|
|
be2fffad23 | ||
|
|
34230e5520 | ||
|
|
1949bf9b97 | ||
|
|
7ab60b1048 | ||
|
|
0e6c31b8ce | ||
|
|
a2cfb693e0 | ||
|
|
10df3ebb35 | ||
|
|
ab91c138d5 | ||
|
|
cc1d1e5746 | ||
|
|
8c75d0070d | ||
|
|
4df04d0504 | ||
|
|
38bc594ca4 | ||
|
|
fb9c9d91ee | ||
|
|
ee69346eb4 | ||
|
|
e6c47019d5 | ||
|
|
b894bbf5e6 | ||
|
|
0f8fed605c | ||
|
|
479b177e90 | ||
|
|
08210b8e52 | ||
|
|
b5dfe17b73 | ||
|
|
49b5ccf308 | ||
|
|
434c4d0a11 | ||
|
|
dc7ed0728a | ||
|
|
b1dbb15ad7 | ||
|
|
e76170b6dd | ||
|
|
8225d4e92d | ||
|
|
a5addf8d33 | ||
|
|
0542a669bd | ||
|
|
49b7a3fe34 | ||
|
|
ead2c092da | ||
|
|
6e818c3480 | ||
|
|
a02490ea36 | ||
|
|
9eda12981a | ||
|
|
4939a1a9ad | ||
|
|
989f599b54 | ||
|
|
67c642bc2f | ||
|
|
cba0f747cd | ||
|
|
c3008d7f7a | ||
|
|
7304fb50c6 | ||
|
|
ec1bba7c46 | ||
|
|
b66d453881 | ||
|
|
b7b80296e9 | ||
|
|
2f582f0548 | ||
|
|
fad28c2442 | ||
|
|
7f1ef4059d | ||
|
|
139ec3c1db | ||
|
|
798beae597 | ||
|
|
3edf5a80aa | ||
|
|
fc93b28af0 | ||
|
|
cd82b70139 | ||
|
|
fba9edc931 | ||
|
|
8e43edcbaf | ||
|
|
8984b2edd5 | ||
|
|
32ec2c1deb | ||
|
|
25029dff5d | ||
|
|
875b181aff | ||
|
|
0f3d9b87fa | ||
|
|
d7929dd855 | ||
|
|
8a495346d6 | ||
|
|
fef4997132 | ||
|
|
b4eaf695c7 | ||
|
|
372490ed5a | ||
|
|
e37b6234e8 | ||
|
|
71b42786f7 | ||
|
|
009eabd09c | ||
|
|
49a4393980 | ||
|
|
c0e20a15fe | ||
|
|
061317ed4c | ||
|
|
a5dce776de | ||
|
|
90ae78262a | ||
|
|
0809f07d46 | ||
|
|
199ef08e35 | ||
|
|
4930a2ba42 | ||
|
|
0ceabe465b | ||
|
|
bd7b70e465 | ||
|
|
55a24b99f0 | ||
|
|
122df1a3f5 | ||
|
|
391dd63ebf | ||
|
|
9d44197246 | ||
|
|
582fb9b52d | ||
|
|
eed7ba4d7b | ||
|
|
5642193610 | ||
|
|
1a1e120cf6 | ||
|
|
cf66b85993 | ||
|
|
8677e9a8a5 | ||
|
|
906f591979 | ||
|
|
97cc0ac422 | ||
|
|
bdc7100f74 | ||
|
|
29a51ed66d | ||
|
|
c9a258a350 | ||
|
|
096dd96db0 | ||
|
|
ddd5971c06 | ||
|
|
b7638f5e9e | ||
|
|
b197152f12 | ||
|
|
297becf7a0 | ||
|
|
b6f991b291 | ||
|
|
5469c834a6 | ||
|
|
a3ea02a136 | ||
|
|
195246af89 | ||
|
|
2953ed0b2d | ||
|
|
ccb2fa75ac | ||
|
|
a33e6a0dd4 | ||
|
|
435e3a79e8 | ||
|
|
21054f0c45 | ||
|
|
605b6ca64c | ||
|
|
bc9616e7de | ||
|
|
491169b9d7 | ||
|
|
0e0ce254a9 | ||
|
|
7b25fbf28b | ||
|
|
2a5100e76d | ||
|
|
e98695ea06 | ||
|
|
1c2421fb7e | ||
|
|
76aeb101bd | ||
|
|
60b655a195 | ||
|
|
369d660e24 | ||
|
|
47703dc205 | ||
|
|
25bbf3d44f | ||
|
|
803b10f981 | ||
|
|
b992e4a5ea | ||
|
|
9c5597eb23 | ||
|
|
2ec61d5086 | ||
|
|
8bae958e24 | ||
|
|
e7f0c09b36 | ||
|
|
ae2ed306b9 | ||
|
|
2b122a7cc4 | ||
|
|
787e76c21c | ||
|
|
7b1f2e7468 | ||
|
|
3f94c51bc8 | ||
|
|
64d51c01b2 | ||
|
|
955dbd2f05 | ||
|
|
318a326fdb | ||
|
|
86223dc8a6 | ||
|
|
4bb62830a4 | ||
|
|
4ce731bfcb | ||
|
|
f225dac5fe | ||
|
|
5e1f2a8711 | ||
|
|
8746397d83 | ||
|
|
e8c27cb51b | ||
|
|
d2a80ea8fd | ||
|
|
c85c72863c | ||
|
|
77afc0fe7b | ||
|
|
7a53908b80 | ||
|
|
ca4495169f | ||
|
|
d1344bbfb4 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
|
||||
# app's CSS, so the JS isn't transpiled for them either.
|
||||
defaults and fully supports css-cascade-layers
|
||||
not chrome < 111
|
||||
@@ -0,0 +1,11 @@
|
||||
name: opencode
|
||||
repository: /Users/fromme/Development/Misc/ddev-opencode
|
||||
version: ""
|
||||
install_date: "2026-01-05T17:21:57+01:00"
|
||||
project_files:
|
||||
- web-build/Dockerfile.opencode
|
||||
- commands/web/opencode
|
||||
- config.opencode.yaml
|
||||
- opencode/.gitignore
|
||||
global_files: []
|
||||
removal_actions: []
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
#ddev-generated
|
||||
|
||||
## Description: OpenCode AI coding assistant
|
||||
## Usage: opencode
|
||||
## Example: ddev opencode [...]
|
||||
|
||||
set -e
|
||||
|
||||
opencode $@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#ddev-generated
|
||||
webimage_extra_packages:
|
||||
- xdg-utils
|
||||
hooks:
|
||||
post-start:
|
||||
- exec: "mkdir -p /var/www/html/.ddev/opencode/.opencode && ln -sf /var/www/html/.ddev/opencode/.opencode ~/.opencode"
|
||||
@@ -1,63 +1,44 @@
|
||||
name: myep
|
||||
type: php
|
||||
docroot: public
|
||||
php_version: "8.5"
|
||||
php_version: "8.2"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames:
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: [ ]
|
||||
additional_hostnames: []
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
|
||||
type: mariadb
|
||||
version: "10.11"
|
||||
use_dns_when_possible: true
|
||||
timezone: Europe/Berlin
|
||||
composer_version: "2"
|
||||
web_environment: [ ]
|
||||
nodejs_version: "24"
|
||||
web_environment: []
|
||||
corepack_enable: false
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
# name: <projectname> # Name of the project, automatically provides
|
||||
# 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> # 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
|
||||
# type: <projecttype> # backdrop, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress
|
||||
# See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
# "drupal" covers recent Drupal 8+
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||
# php_version: "8.2" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"
|
||||
|
||||
# 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.
|
||||
|
||||
# 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.*
|
||||
# webimage: <docker_image> # nginx/php docker image.
|
||||
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
|
||||
# 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.*
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, and 11.4.
|
||||
# MySQL versions can be 5.5-8.0.
|
||||
# PostgreSQL versions can be 9-17.
|
||||
|
||||
# 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)
|
||||
@@ -67,20 +48,12 @@ omit_containers: [ddev-ssh-agent]
|
||||
# "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.
|
||||
|
||||
# xhgui_http_port: "8143"
|
||||
# xhgui_https_port: "8142"
|
||||
# The XHGui ports can be changed from the default 8143 and 8142
|
||||
# Very rarely used
|
||||
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart"
|
||||
# Note that for most people the commands
|
||||
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better,
|
||||
# as leaving Xhprof enabled all the time is a big performance hit.
|
||||
|
||||
# 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
|
||||
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
|
||||
|
||||
# timezone: Europe/Berlin
|
||||
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
|
||||
@@ -96,24 +69,22 @@ omit_containers: [ddev-ssh-agent]
|
||||
# commands are executed.
|
||||
|
||||
# composer_version: "2"
|
||||
# You can set it to "" or "2" (default) for Composer v2
|
||||
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
|
||||
# to use the latest major version available at the time your container is built.
|
||||
# It is also possible to use any other Composer version channel. This includes:
|
||||
# It is also possible to use each other Composer version channel. This includes:
|
||||
# - 2.2 (latest Composer LTS version)
|
||||
# - stable
|
||||
# - preview
|
||||
# - snapshot
|
||||
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
||||
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||
# To reinstall Composer after the image was built, run "ddev debug rebuild".
|
||||
|
||||
# nodejs_root: <relative_path>
|
||||
# Relative path to the directory containing the Node.js version file from the
|
||||
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
|
||||
|
||||
# nodejs_version: "24"
|
||||
# nodejs_version: "20"
|
||||
# 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.
|
||||
# See https://ddev.readthedocs.io/en/stable/users/configuration/config/#nodejs_version for more information
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
|
||||
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
|
||||
# can specify any version, and is more robust than using 'nvm'.
|
||||
|
||||
# corepack_enable: false
|
||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||
@@ -146,7 +117,7 @@ omit_containers: [ddev-ssh-agent]
|
||||
|
||||
# ddev_version_constraint: ""
|
||||
# Example:
|
||||
# ddev_version_constraint: ">= 1.24.8"
|
||||
# ddev_version_constraint: ">= 1.22.4"
|
||||
# This will enforce that the running ddev version is within this constraint.
|
||||
# See https://github.com/Masterminds/semver#checking-version-constraints for
|
||||
# supported constraint formats
|
||||
@@ -173,8 +144,10 @@ omit_containers: [ddev-ssh-agent]
|
||||
# - "global": uses the value from the global config.
|
||||
# - "none": disables performance optimization for this project.
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
# - "nfs": enables NFS for this project.
|
||||
#
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
# Decide whether 'ddev start' should be interrupted by a failing hook
|
||||
@@ -203,10 +176,10 @@ omit_containers: [ddev-ssh-agent]
|
||||
# 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.
|
||||
|
||||
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
|
||||
# Extra Debian packages that are needed in the webimage can be added here
|
||||
|
||||
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||
# dbimage_extra_packages: [telnet,netcat]
|
||||
# Extra Debian packages that are needed in the dbimage can be added here
|
||||
|
||||
# use_dns_when_possible: true
|
||||
@@ -218,15 +191,12 @@ omit_containers: [ddev-ssh-agent]
|
||||
# project_tld: ddev.site
|
||||
# The top-level domain used for project URLs
|
||||
# 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.
|
||||
|
||||
# share_default_provider: ngrok
|
||||
# The default share provider to use for "ddev share"
|
||||
# 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
|
||||
# ngrok_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the "ngrok http" command, see
|
||||
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -288,7 +258,7 @@ omit_containers: [ddev-ssh-agent]
|
||||
# override_config: false
|
||||
# By default, config.*.yaml files are *merged* into the configuration
|
||||
# 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.
|
||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||
# 'use_dns_when_possible: false' can override the existing values, and
|
||||
@@ -298,13 +268,13 @@ omit_containers: [ddev-ssh-agent]
|
||||
# web_environment: []
|
||||
# or
|
||||
# additional_hostnames: []
|
||||
# can have their intended effect. 'override_config' affects only behavior of the
|
||||
# can have their intended affect. 'override_config' affects only behavior of the
|
||||
# config.*.yaml file it exists in.
|
||||
|
||||
# Many DDEV commands can be extended to run tasks before or after the
|
||||
# DDEV command is executed, for example "post-start", "post-import-db",
|
||||
# "pre-composer", "post-composer"
|
||||
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
|
||||
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more
|
||||
# information on the commands that can be extended and the tasks you can define
|
||||
# for them. Example:
|
||||
#hooks:
|
||||
@@ -0,0 +1,5 @@
|
||||
#ddev-generated
|
||||
|
||||
# OpenCode internal config
|
||||
/.opencode/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#ddev-generated
|
||||
|
||||
RUN npm install -g opencode-ai
|
||||
|
||||
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.11-MariaDB&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
@@ -40,96 +40,32 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
|
||||
MAILJET_DEFAULT_LIST_ID=
|
||||
# Development default for HTTP Basic Auth password; override in production secrets.
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||
|
||||
# Dedicated signing secret for accommodation booking customer access links (kept separate
|
||||
# from APP_SECRET so it can be rotated independently). Unlike lazily-used API keys, UriSigner
|
||||
# validates this is non-empty at construction time, so a real dev value is required here
|
||||
# (override in .env.local/production secrets as usual).
|
||||
ACCOMMODATION_OFFER_LINK_SECRET=abf8ec28cc2162d9121d35d6f8cbf7c29728cdacce0303e22e1bac854501728a
|
||||
|
||||
ACCOMMODATION_TERMS_AT=
|
||||
ACCOMMODATION_TERMS_CH=
|
||||
ACCOMMODATION_TERMS_IT=
|
||||
|
||||
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
||||
BPN_CONNECT_API_KEY=
|
||||
|
||||
# ClickUp task created when an accommodation booking is confirmed. Token/ids are secrets and
|
||||
# stay empty here — set them in .env.local. Without them the handler skips with a warning.
|
||||
CLICKUP_API_TOKEN=
|
||||
CLICKUP_LIST_ID=
|
||||
CLICKUP_TEMPLATE_ID=
|
||||
CLICKUP_API_BASE_URL=https://api.clickup.com/api/v2
|
||||
|
||||
APP_BPN_USER=
|
||||
APP_BPN_PASSWORD=
|
||||
APP_BPN_IP=
|
||||
APP_BPN_PORTS=
|
||||
APP_BPN_PORT=
|
||||
APP_BPN_DEBUG=false
|
||||
APP_BPN_CONNECTION_TIMEOUT=5
|
||||
APP_BPN_STREAM_TIMEOUT=30
|
||||
APP_BPN_TOTAL_TIMEOUT=45
|
||||
|
||||
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
|
||||
APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/"
|
||||
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
|
||||
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
|
||||
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync;
|
||||
# the real value per stage goes in .env.local
|
||||
APP_CMS_API_KEY=
|
||||
# Bearer tokens for the chatbot MCP endpoints (/mcp, /mcp/trips), one per client: "name:token,name:token"
|
||||
# (e.g. "n8n:…,claude-desktop:…"); empty refuses every request
|
||||
APP_MCP_TOKENS=
|
||||
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
|
||||
APP_BASE_URL="https://my.ep-reisen.de"
|
||||
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||
APP_INSURANCE_REVOCATION_URL="https://www.hmrv.de/daten-aendern/vertrag-beenden/widerruf"
|
||||
|
||||
# Travel Data Service Configuration
|
||||
APP_TRAVEL_PREFER_REMOTE=false
|
||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||
|
||||
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
|
||||
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
|
||||
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
|
||||
APP_HTMX_BOOST=true
|
||||
API_KEYS=
|
||||
|
||||
# Emails
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
||||
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
|
||||
APP_ROLE_NOMINATION_EMAILS=
|
||||
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
||||
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
|
||||
|
||||
# Global common defaults
|
||||
APP_SEASON_WINTER_FROM=2026-10-01
|
||||
APP_SEASON_SUMMER_FROM=2026-05-01
|
||||
APP_RUNNING_COSTS_FACTOR_EUR=2.75
|
||||
APP_RUNNING_COSTS_FACTOR_CHF=3.2
|
||||
APP_UNDERSUBSCRIPTION_SURCHARGE_30_EUR=5.0
|
||||
APP_UNDERSUBSCRIPTION_SURCHARGE_30_CHF=5.0
|
||||
APP_UNDERSUBSCRIPTION_SURCHARGE_40_EUR=2.5
|
||||
APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
|
||||
|
||||
# Booking Configuration
|
||||
# Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation)
|
||||
# Use 'O' during beta phase, switch to 'F' for production
|
||||
DEFAULT_BOOKING_STATUS=F
|
||||
|
||||
SFTP_XML_SOURCE_HOST=
|
||||
SFTP_XML_SOURCE_PORT=
|
||||
SFTP_XML_SOURCE_USER=
|
||||
SFTP_XML_SOURCE_PASSWORD=
|
||||
SFTP_XML_SOURCE_ROOT=
|
||||
SFTP_XML_EXPORT_HOST=
|
||||
SFTP_XML_EXPORT_PORT=
|
||||
SFTP_XML_EXPORT_USER=
|
||||
SFTP_XML_EXPORT_PASSWORD=
|
||||
|
||||
###> league/oauth2-server-bundle ###
|
||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||
@@ -142,8 +78,3 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
MAINTENANCE_MODE_ENABLED=false
|
||||
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||
MAINTENANCE_MODE_ROUTE_WHITELIST=
|
||||
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
|
||||
@@ -4,4 +4,3 @@ APP_SECRET='$ecretf0rt3st'
|
||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||
PANTHER_APP_ENV=panther
|
||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
@@ -8,9 +8,13 @@
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> symfony/phpunit-bridge ###
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
/phpunit.xml
|
||||
###< symfony/phpunit-bridge ###
|
||||
|
||||
@@ -27,13 +31,3 @@ yarn-error.log
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
|
||||
/ray.php
|
||||
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
###> config reference ###
|
||||
/config/reference.php
|
||||
###< config reference ###
|
||||
@@ -60,7 +60,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API travel
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/travels/DPWMP140326
|
||||
GET {{base_url}}/api/travels/SBW131225
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
@@ -72,7 +72,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API hotel availability remote
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/travels/11606/152546/2026-01-24/availability
|
||||
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
@@ -90,50 +90,16 @@ Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
{
|
||||
"lastName": "{{$random.name.lastName}}",
|
||||
"firstName": "{{$random.name.firstName}}",
|
||||
"gender": "d",
|
||||
"street": "{{$random.address.streetAddress}}",
|
||||
"zipCode": "{{$random.address.zipCode}}",
|
||||
"city": "{{$random.address.city}}",
|
||||
"email": "{{$random.email}}",
|
||||
"phone": "{{$random.phoneNumber.cellPhone}}"
|
||||
"lastName": "Duschen",
|
||||
"firstName": "Isolde",
|
||||
"gender": "f",
|
||||
"street": "Fooroad 1",
|
||||
"zipCode": "12345",
|
||||
"city": "Bartown",
|
||||
"email": "[email protected]",
|
||||
"phone": "12345"
|
||||
}
|
||||
|
||||
### API newsletter lists map
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/newsletters
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_newsletter")}}
|
||||
|
||||
### API newsletter subscriptions
|
||||
# @no-cookie-jar
|
||||
POST {{base_url}}/api/newsletter-subscriptions
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_newsletter")}}
|
||||
|
||||
{
|
||||
"email": "{{$random.email}}",
|
||||
"firstName": "{{$random.address.firstName}}",
|
||||
"lastName": "{{$random.address.lastName}}",
|
||||
"listIds": [10321569,10321382,10321383]
|
||||
}
|
||||
|
||||
### API contingent calendar
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/contingents/calendar?hotelCode=DGS&dateFrom=2026-06-01&dateTo=2026-06-30
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API contintent prices
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/contingents/prices?hotelCode=DGS&year=2026
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API pickups planning webhook
|
||||
# @no-cookie-jar
|
||||
POST {{base_url}}/api/pickups-planning
|
||||
@@ -149,10 +115,3 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API accommodation booking
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/accommodation-bookings/e4591076-eb65-41e8-a115-09d18d15604a
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
@@ -1,8 +0,0 @@
|
||||
import htmx from 'htmx.org'
|
||||
htmx.config.includeIndicatorStyles = false
|
||||
htmx.config.historyCacheSize = 0
|
||||
htmx.config.allowScriptTags = false
|
||||
|
||||
import './bootstrap.js'
|
||||
|
||||
import './styles/admin.css'
|
||||
@@ -13,12 +13,3 @@ htmx.config.allowScriptTags = false
|
||||
htmx.config.withCredentials = true
|
||||
htmx.config.selfRequestsOnly = false
|
||||
htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s)
|
||||
|
||||
document.body.addEventListener('htmx:beforeSwap', (event) => {
|
||||
// Symfony renders invalid forms with 422; let HTMX swap those responses so inline
|
||||
// validation errors are actually shown to the user.
|
||||
if (event.detail?.xhr?.status === 422) {
|
||||
event.detail.shouldSwap = true
|
||||
event.detail.isError = false
|
||||
}
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { useFetch } from '../mixins/use_fetch'
|
||||
import Autocomplete from '@trevoreyre/autocomplete-js'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'input', 'field', 'resetButton', 'searchIcon' ]
|
||||
|
||||
static values = { url: String, choices: Array, selected: Boolean }
|
||||
|
||||
initialize() {
|
||||
useFetch(this)
|
||||
}
|
||||
|
||||
connect() {
|
||||
const searchHandler = input => {
|
||||
return new Promise(resolve => {
|
||||
// Check input minimum length
|
||||
if (input.length < 2) {
|
||||
return resolve([])
|
||||
}
|
||||
|
||||
// Search choices if provided
|
||||
if (this.choicesValue.length > 0) {
|
||||
return resolve(this.choicesValue.filter(choice => {
|
||||
return -1 !== choice.text.toLowerCase().search(input.toLowerCase())
|
||||
}))
|
||||
}
|
||||
|
||||
// Use API otherwise
|
||||
let data = { search: input }
|
||||
fetch(this.urlValue, { method: 'POST', body: JSON.stringify(data), credentials: 'include' })
|
||||
.then(this.checkStatus)
|
||||
.then(this.parseJSON)
|
||||
.then(data => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const submitHandler = result => {
|
||||
this.fieldTarget.value = result.value
|
||||
this.selectedValue = true
|
||||
this.element.dispatchEvent(new CustomEvent('select', {
|
||||
detail: { result }
|
||||
}))
|
||||
}
|
||||
|
||||
this.autocomplete = new Autocomplete(this.element, {
|
||||
search: searchHandler,
|
||||
getResultValue: result => result.text,
|
||||
submitOnEnter: true,
|
||||
onSubmit: submitHandler,
|
||||
debounceTime: 250,
|
||||
})
|
||||
|
||||
if (this.fieldTarget.value) {
|
||||
this.selectedValue = true
|
||||
}
|
||||
|
||||
// prevent form submission when selecting entry in autocomplete list with enter key
|
||||
this.element.addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.inputTarget.value = null
|
||||
this.fieldTarget.value = null
|
||||
this.selectedValue = false
|
||||
this.element.dispatchEvent(new CustomEvent('reset'))
|
||||
}
|
||||
|
||||
selectedValueChanged() {
|
||||
this.resetButtonTarget.classList.toggle('hidden', false === this.selectedValue)
|
||||
this.searchIconTarget.classList.toggle('hidden', this.selectedValue)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autocomplete.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Monitors birthday input fields (day, month, year) and dispatches a custom event
|
||||
* only when all three fields are filled with valid values.
|
||||
*
|
||||
* This prevents premature HTMX refreshes when users are still entering the date,
|
||||
* improving UX by waiting until the complete date is entered.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['day', 'month', 'year']
|
||||
|
||||
/**
|
||||
* Checks if all birthday fields are filled and dispatches a completion event.
|
||||
*
|
||||
* Called on input/change events from the day, month, and year fields.
|
||||
* Only dispatches the 'birthday:complete' event when all three fields
|
||||
* contain valid values that could form a complete date.
|
||||
*/
|
||||
check() {
|
||||
const day = this.dayTarget.value.trim()
|
||||
const month = this.monthTarget.value.trim()
|
||||
const year = this.yearTarget.value.trim()
|
||||
|
||||
if (false === this.isComplete(day, month, year)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.element.dispatchEvent(new CustomEvent('birthday:complete', { bubbles: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all date components are filled with plausible values.
|
||||
*
|
||||
* @param {string} day The day value
|
||||
* @param {string} month The month value
|
||||
* @param {string} year The year value
|
||||
*
|
||||
* @returns {boolean} True if all fields contain complete values
|
||||
*/
|
||||
isComplete(day, month, year) {
|
||||
// Day: 1-2 digits
|
||||
if (0 === day.length || day.length > 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Month: 1-2 digits
|
||||
if (0 === month.length || month.length > 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Year: exactly 4 digits for a complete year
|
||||
if (4 !== year.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static values = {
|
||||
content: String,
|
||||
label: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.originalLabel = this.element.innerText
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this.resetTimer)
|
||||
}
|
||||
|
||||
copy() {
|
||||
navigator.clipboard.writeText(this.contentValue).then(() => {
|
||||
this.dispatch('copied', {
|
||||
detail: {
|
||||
content: this.contentValue,
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
})
|
||||
|
||||
if (this.hasLabelValue) {
|
||||
this.element.innerText = this.labelValue
|
||||
clearTimeout(this.resetTimer)
|
||||
this.resetTimer = setTimeout(() => {
|
||||
this.element.innerText = this.originalLabel
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
payload: Object
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (typeof window.dataLayer !== 'undefined') {
|
||||
window.dataLayer.push(this.payloadValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { Calendar } from 'vanilla-calendar-pro'
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = ['field', 'hiddenField']
|
||||
static values = {
|
||||
minDate: String,
|
||||
maxDate: String,
|
||||
disableWeekends: Boolean,
|
||||
disabledDates: Array,
|
||||
}
|
||||
|
||||
connect() {
|
||||
const options = {
|
||||
inputMode: true,
|
||||
locale: 'de-DE',
|
||||
selectedTheme: 'light',
|
||||
selectedDates: this.hiddenFieldTarget.value ? [this.hiddenFieldTarget.value] : [],
|
||||
onClickDate: (self) => {
|
||||
const iso = self.context.selectedDates[0] ?? null
|
||||
this.hiddenFieldTarget.value = iso ?? ''
|
||||
if (iso) {
|
||||
const [year, month, day] = iso.split('-')
|
||||
this.fieldTarget.value = `${day}.${month}.${year}`
|
||||
} else {
|
||||
this.fieldTarget.value = ''
|
||||
}
|
||||
this.element.dispatchEvent(new CustomEvent('datepicker:picked', {
|
||||
detail: { date: this.fieldTarget.value }
|
||||
}))
|
||||
},
|
||||
onHide: () => {
|
||||
this.element.dispatchEvent(new CustomEvent('datepicker:closed'))
|
||||
},
|
||||
}
|
||||
|
||||
if (this.minDateValue) options.dateMin = this.minDateValue
|
||||
if (this.maxDateValue) options.dateMax = this.maxDateValue
|
||||
if (this.disableWeekendsValue) options.disableWeekdays = [0, 6]
|
||||
if (this.disabledDatesValue.length) options.disableDates = this.disabledDatesValue
|
||||
|
||||
this.picker = new Calendar(this.fieldTarget, options)
|
||||
this.picker.init()
|
||||
|
||||
this.handleManualInput = () => {
|
||||
const value = this.fieldTarget.value.trim()
|
||||
if (!value) {
|
||||
this.hiddenFieldTarget.value = ''
|
||||
this.picker.set({ selectedDates: [] }, { dates: true })
|
||||
} else {
|
||||
const match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/)
|
||||
if (match) {
|
||||
const [, day, month, year] = match
|
||||
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
this.hiddenFieldTarget.value = iso
|
||||
this.picker.set({ selectedDates: [iso] }, { dates: true })
|
||||
}
|
||||
}
|
||||
this.element.dispatchEvent(new CustomEvent('datepicker:closed'))
|
||||
}
|
||||
this.fieldTarget.addEventListener('change', this.handleManualInput)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.fieldTarget.removeEventListener('change', this.handleManualInput)
|
||||
this.picker.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Keeps buttons disabled until the given fields pass the browser's constraint validation
|
||||
* (required, min, max, step, …), which the form types render from the same rules the server
|
||||
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
|
||||
*
|
||||
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['field', 'button']
|
||||
|
||||
connect() {
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|
||||
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
|
||||
this.buttonTargets.forEach(button => button.disabled = invalid)
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,10 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.itemsCountValue = this.fieldTargets.length
|
||||
// A running counter rather than the current count: after a remove, count + 1 can be an
|
||||
// index a remaining entry still holds, and the two would submit into the same slot.
|
||||
this.nextIndex = this.fieldTargets.length
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const index = this.nextIndex++
|
||||
const index = this.fieldTargets.length + 1
|
||||
const prototype = JSON.parse(this.prototypeValue)
|
||||
const newField = prototype.replace(/__name__/g, index)
|
||||
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Shows an indicator over an iframe until its content has loaded.
|
||||
*
|
||||
* The controller sets the frame's src itself, after the load listener is attached, so a fast
|
||||
* response cannot finish before anybody listens.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['frame', 'indicator']
|
||||
static values = {src: String}
|
||||
|
||||
connect() {
|
||||
this.loaded = this.loaded.bind(this)
|
||||
this.frameTarget.addEventListener('load', this.loaded)
|
||||
this.frameTarget.src = this.srcValue
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.frameTarget.removeEventListener('load', this.loaded)
|
||||
}
|
||||
|
||||
loaded() {
|
||||
this.indicatorTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import GLightbox from 'glightbox'
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = ['item']
|
||||
|
||||
connect() {
|
||||
this.lightbox = GLightbox({
|
||||
elements: this.itemTargets.map((el) => ({
|
||||
href: el.dataset.galleryHref,
|
||||
type: 'image',
|
||||
alt: el.dataset.galleryAlt ?? '',
|
||||
})),
|
||||
})
|
||||
|
||||
this.itemTargets.forEach((el, index) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
this.lightbox.openAt(index)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.lightbox.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static outlets = [ 'mobilenav' ]
|
||||
static classes = [ 'closed' ]
|
||||
|
||||
trigger({ params: { mode } }) {
|
||||
this.mobilenavOutlet.toggle(mode)
|
||||
}
|
||||
|
||||
toggle(mode) {
|
||||
// Add animation classes only now to avoid fouc
|
||||
this.element.classList.add('transition-transform', 'duration-300')
|
||||
this.element.classList.toggle(this.closedClass, 'close' === mode)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { useClickOutside } from 'stimulus-use'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'label', 'dropdown', 'choice' ]
|
||||
static values = { placeholder: String }
|
||||
|
||||
connect() {
|
||||
this.update()
|
||||
useClickOutside(this)
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.dropdownTarget.classList.toggle('hidden')
|
||||
}
|
||||
|
||||
update() {
|
||||
let labels = []
|
||||
|
||||
this.choiceTargets.forEach((choice) => {
|
||||
if (choice.checked) {
|
||||
labels.push(choice.dataset.label)
|
||||
}
|
||||
this.labelTarget.innerText = labels.join(', ')
|
||||
})
|
||||
|
||||
if (0 === labels.length) {
|
||||
this.labelTarget.innerText = this.placeholderValue
|
||||
}
|
||||
}
|
||||
|
||||
clickOutside(e) {
|
||||
this.dropdownTarget.classList.toggle('hidden', true)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'field' ]
|
||||
|
||||
toggle() {
|
||||
let type = this.fieldTarget.getAttribute('type') === 'password' ? 'text' : 'password'
|
||||
this.fieldTarget.setAttribute('type', type)
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = ['mount', 'error', 'message', 'resetButton', 'day', 'dateFrom', 'dateTo', 'submit']
|
||||
|
||||
static values = {
|
||||
hotelCode: String,
|
||||
selectedFrom: String,
|
||||
selectedTo: String,
|
||||
calendarRefreshUrl: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.startDate = null
|
||||
this.endDate = null
|
||||
this.sessionRestored = false
|
||||
|
||||
// Re-apply highlights after every HTMX month swap (navigation or initial load).
|
||||
this.mountTarget.addEventListener('htmx:afterSwap', () => requestAnimationFrame(() => this.initAfterLoad()))
|
||||
this.mountTarget.addEventListener('htmx:responseError', () => this.showError())
|
||||
}
|
||||
|
||||
initAfterLoad() {
|
||||
// Restore from session values exactly once on the first swap.
|
||||
if (!this.sessionRestored) {
|
||||
this.sessionRestored = true
|
||||
const from = this.selectedFromValue
|
||||
const to = this.selectedToValue
|
||||
if (from && to) {
|
||||
this.startDate = from
|
||||
this.endDate = to
|
||||
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
this.updateDisplay()
|
||||
}
|
||||
|
||||
selectDate(event) {
|
||||
const btn = event.currentTarget
|
||||
btn.blur()
|
||||
const date = btn.dataset.date
|
||||
const status = btn.dataset.status
|
||||
|
||||
if (!status || status === 'blocked' || status === 'past') return
|
||||
|
||||
if (this.startDate && this.endDate) {
|
||||
this.resetSelection()
|
||||
}
|
||||
|
||||
if (!this.startDate) {
|
||||
if (status === 'checkout-only') {
|
||||
this.showMessage('Bitte wähle einen Anreisetag.')
|
||||
return
|
||||
}
|
||||
this.startDate = date
|
||||
this.endDate = null
|
||||
this.updateDisplay()
|
||||
this.showMessage('Bitte wähle das Abreisedatum.')
|
||||
return
|
||||
}
|
||||
|
||||
if (date === this.startDate) {
|
||||
this.resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
const [from, to] = date < this.startDate ? [date, this.startDate] : [this.startDate, date]
|
||||
|
||||
if (this.hasBlockedInRange(from, to)) {
|
||||
this.resetSelection()
|
||||
this.showMessage('Dieser Zeitraum enthält nicht verfügbare Daten.')
|
||||
return
|
||||
}
|
||||
|
||||
const startBtn = this.dayTargets.find(b => b.dataset.date === from)
|
||||
const minNights = parseInt(startBtn?.dataset.minNights) || 0
|
||||
const nights = (new Date(to) - new Date(from)) / 86400000
|
||||
|
||||
if (minNights > 0 && nights < minNights) {
|
||||
this.showMessage(`Weniger als ${minNights} Nächte nur auf Anfrage.`)
|
||||
} else {
|
||||
this.hideMessage()
|
||||
}
|
||||
|
||||
this.startDate = from
|
||||
this.endDate = to
|
||||
this.updateDisplay()
|
||||
this.activateSubmit(from, to)
|
||||
this.saveAndRefreshSummary(from, to)
|
||||
this.dispatch('range-select', { detail: { dateFrom: from, dateTo: to } })
|
||||
}
|
||||
|
||||
hasBlockedInRange(from, to) {
|
||||
for (const btn of this.dayTargets) {
|
||||
const d = btn.dataset.date
|
||||
const s = btn.dataset.status
|
||||
if (d >= from && d < to && (s === 'blocked' || s === 'past' || s === 'checkout-only')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
for (const btn of this.dayTargets) {
|
||||
const d = btn.dataset.date
|
||||
btn.classList.remove('pc-day--pending-start', 'pc-day--selected-start', 'pc-day--selected-end', 'pc-day--in-range')
|
||||
if (!this.startDate) continue
|
||||
if (d === this.startDate) {
|
||||
btn.classList.add(this.endDate ? 'pc-day--selected-start' : 'pc-day--pending-start')
|
||||
} else if (this.endDate) {
|
||||
if (d === this.endDate) btn.classList.add('pc-day--selected-end')
|
||||
else if (d > this.startDate && d < this.endDate) btn.classList.add('pc-day--in-range')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activateSubmit(dateFrom, dateTo) {
|
||||
if (this.hasDateFromTarget) this.dateFromTarget.value = dateFrom
|
||||
if (this.hasDateToTarget) this.dateToTarget.value = dateTo
|
||||
if (this.hasSubmitTarget) {
|
||||
this.submitTarget.disabled = false
|
||||
this.submitTarget.classList.remove('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
|
||||
}
|
||||
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
resetSelection() {
|
||||
this.startDate = null
|
||||
this.endDate = null
|
||||
this.updateDisplay()
|
||||
this.hideMessage()
|
||||
if (this.hasDateFromTarget) this.dateFromTarget.value = ''
|
||||
if (this.hasDateToTarget) this.dateToTarget.value = ''
|
||||
if (this.hasSubmitTarget) {
|
||||
this.submitTarget.disabled = true
|
||||
this.submitTarget.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
|
||||
}
|
||||
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.add('hidden')
|
||||
this.saveAndRefreshSummary()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetSelection()
|
||||
}
|
||||
|
||||
saveAndRefreshSummary(dateFrom = '', dateTo = '') {
|
||||
if (!this.hasCalendarRefreshUrlValue) return
|
||||
htmx.ajax('POST', this.calendarRefreshUrlValue, {
|
||||
values: { date_from: dateFrom, date_to: dateTo },
|
||||
swap: 'none',
|
||||
target: this.element,
|
||||
})
|
||||
}
|
||||
|
||||
showMessage(text) {
|
||||
if (this.hasMessageTarget) this.messageTarget.textContent = text
|
||||
}
|
||||
|
||||
hideMessage() {
|
||||
if (this.hasMessageTarget) this.messageTarget.textContent = ''
|
||||
}
|
||||
|
||||
showError() {
|
||||
if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Unchecks conflicting additional services as soon as one is selected, so the user sees the
|
||||
* result immediately instead of waiting for the debounced htmx refresh. The server resolves
|
||||
* the same conflicts again and stays the source of truth.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'input' ]
|
||||
|
||||
select(event) {
|
||||
const input = event.target
|
||||
|
||||
if (!input.checked) {
|
||||
return
|
||||
}
|
||||
|
||||
const group = input.dataset.exclusionGroup
|
||||
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
|
||||
const exclusive = '1' === input.dataset.exclusive
|
||||
|
||||
this.inputTargets
|
||||
.filter(other => other !== input)
|
||||
.filter(other => other.dataset.exclusionGroup === group)
|
||||
.filter(other => exclusive || '1' === other.dataset.exclusive)
|
||||
.forEach(other => { other.checked = false })
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static classes = ['closed', 'iconClosed', 'iconExpanded']
|
||||
static targets = ['submenu', 'button', 'icon']
|
||||
static values = { expanded: Boolean }
|
||||
|
||||
toggleSubmenu() {
|
||||
this.expandedValue = !this.expandedValue
|
||||
}
|
||||
|
||||
expandedValueChanged(expanded) {
|
||||
this.submenuTarget.classList.toggle(this.closedClass, false === expanded)
|
||||
if (false === this.hasButtonTarget) {
|
||||
return
|
||||
}
|
||||
this.buttonTarget.setAttribute('aria-expanded', expanded)
|
||||
if (false === this.hasIconTarget) {
|
||||
return
|
||||
}
|
||||
if (true === expanded) {
|
||||
this.iconTarget.classList.add(...this.iconExpandedClasses)
|
||||
this.iconTarget.classList.remove(...this.iconClosedClasses)
|
||||
} else {
|
||||
this.iconTarget.classList.add(...this.iconClosedClasses)
|
||||
this.iconTarget.classList.remove(...this.iconExpandedClasses)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'list', 'item', 'handle', 'positionField', 'positionLabel', 'status' ]
|
||||
|
||||
static values = {
|
||||
url: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.abortController = null
|
||||
|
||||
// The sortable container may be nested (e.g. a tbody) when the controller element also
|
||||
// has to wrap elements that must not be dragged, like the status message.
|
||||
new Sortable(this.hasListTarget ? this.listTarget : this.element, {
|
||||
draggable: '[data-sortable-target="item"]',
|
||||
handle: '[data-sortable-target="handle"]',
|
||||
sort: true,
|
||||
fallbackOnBody: true,
|
||||
onEnd: e => {
|
||||
this.updateOrdering()
|
||||
this.persist()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
updateOrdering() {
|
||||
this.positionFieldTargets.forEach((field, index) => {
|
||||
field.value = index + 1
|
||||
})
|
||||
this.positionLabelTargets.forEach((label, index) => {
|
||||
label.innerText = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the new order to the server. Only active when a url value is present, so the
|
||||
* controller stays usable for plain form ordering.
|
||||
*/
|
||||
persist() {
|
||||
if (!this.hasUrlValue || this.urlValue === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// A drag while the previous request is still running makes that request obsolete.
|
||||
this.abortController?.abort()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
const ids = this.itemTargets
|
||||
.map(item => Number(item.dataset.sortableId))
|
||||
.filter(id => Number.isInteger(id) && id > 0)
|
||||
|
||||
this.setStatus('speichert …')
|
||||
|
||||
fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
signal: this.abortController.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response ${response.status}`)
|
||||
}
|
||||
this.setStatus('Reihenfolge gespeichert')
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
console.error('Failed to persist ordering', error)
|
||||
this.setStatus('Speichern fehlgeschlagen')
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(message) {
|
||||
this.statusTargets.forEach(status => {
|
||||
status.innerText = message
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,24 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
handleNotifications(event) {
|
||||
console.log('[VOUCHER DEBUG] showNotifications event received:', event)
|
||||
console.log('[VOUCHER DEBUG] event.detail:', event.detail)
|
||||
|
||||
// HTMX wraps the trigger value in an object with 'value' and 'elt' properties
|
||||
// event.detail.value contains the actual array from the HX-Trigger header
|
||||
const notifications = event.detail?.value || event.detail || []
|
||||
|
||||
console.log('[VOUCHER DEBUG] notifications array:', notifications)
|
||||
console.log('[VOUCHER DEBUG] notifications count:', Array.isArray(notifications) ? notifications.length : 'not an array')
|
||||
|
||||
if (Array.isArray(notifications)) {
|
||||
notifications.forEach((notification, index) => {
|
||||
console.log(`[VOUCHER DEBUG] Processing notification ${index}:`, notification)
|
||||
const className = this.getClassForType(notification.type)
|
||||
this.showToast(notification.message, className)
|
||||
})
|
||||
} else {
|
||||
console.error('[VOUCHER DEBUG] Notifications is not an array:', notifications)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +66,4 @@ export default class extends Controller {
|
||||
|
||||
return typeMap[type] || 'toastify--info'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {Controller} from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = ['closed', 'open', 'iconOpen']
|
||||
static targets = ['content', 'icon', 'container', 'trigger']
|
||||
static targets = ['content', 'icon', 'container']
|
||||
static values = {
|
||||
open: {
|
||||
type: Boolean,
|
||||
@@ -43,10 +43,6 @@ export default class extends Controller {
|
||||
this.iconTarget.classList.toggle(iconClass, true === open)
|
||||
}
|
||||
|
||||
if (this.hasTriggerTarget) {
|
||||
this.triggerTarget.setAttribute('aria-expanded', String(open))
|
||||
}
|
||||
|
||||
// Toggle class on container element (or controller element) if specified
|
||||
if (this.hasOpenClass) {
|
||||
const container = this.hasContainerTarget ? this.containerTarget : this.element
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import tippy, { hideAll } from 'tippy.js'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['trigger', 'template']
|
||||
@@ -27,7 +27,6 @@ export default class extends Controller {
|
||||
allowHTML: true,
|
||||
interactive: true,
|
||||
appendTo: document.body,
|
||||
onShow(instance) { hideAll({ exclude: instance }) },
|
||||
}
|
||||
}
|
||||
if (this.hasTriggerTarget) {
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 130 130" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(3, 28)">
|
||||
<path d="M107,0.3c-36.4,5.5 -81.6,19.1 -81.6,38.2c0,17.7 41.8,13.2 41.8,13.2c0,0 -67,22.3 -67,-7c0,-33.2 84.5,-44.4 106.8,-44.4Z" fill="#0084d4"/>
|
||||
<path d="M17,73.5c36.4,-5.4 81.6,-19.1 81.6,-38.2c0,-17.7 -41.8,-13.2 -41.8,-13.2c0,0 67,-22.3 67,7c0,33.3 -84.5,44.4 -106.8,44.4Z" fill="#f8c62f"/>
|
||||
<path d="M60.7,34.3l-12.8,4l12.2,0.8l-0.4,-2.4l1,-2.4Z" fill="#0084d4"/>
|
||||
<path d="M75.9,35.2l-9.2,2.9l-3.5,9.7l-1.7,-11.3l4,-10.7l1.7,7.9l8.7,1.5Z" fill="#f8c62f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 839 KiB |
@@ -1,95 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="icon-table" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5" /></symbol>
|
||||
<symbol id="icon-dots" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" /></symbol>
|
||||
<symbol id="icon-feedback" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></symbol>
|
||||
<symbol id="icon-login" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></symbol>
|
||||
<symbol id="icon-logout" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /></symbol>
|
||||
<symbol id="icon-alert" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /></symbol>
|
||||
<symbol id="icon-close" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></symbol>
|
||||
<symbol id="icon-back" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></symbol>
|
||||
<symbol id="icon-list" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></symbol>
|
||||
<symbol id="icon-menu" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></symbol>
|
||||
<symbol id="icon-edit" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></symbol>
|
||||
<symbol id="icon-plus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></symbol>
|
||||
<symbol id="icon-minus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" /></symbol>
|
||||
<symbol id="icon-search" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /></symbol>
|
||||
<symbol id="icon-user" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></symbol>
|
||||
<symbol id="icon-users" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></symbol>
|
||||
<symbol id="icon-delete" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></symbol>
|
||||
<symbol id="icon-check" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></symbol>
|
||||
<symbol id="icon-upload" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></symbol>
|
||||
<symbol id="icon-download" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></symbol>
|
||||
<symbol id="icon-sort" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></symbol>
|
||||
<symbol id="icon-arrow-left" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" /></symbol>
|
||||
<symbol id="icon-chevron-down" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></symbol>
|
||||
<symbol id="icon-arrow-right" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" /></symbol>
|
||||
<symbol id="icon-document" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></symbol>
|
||||
<symbol id="icon-filter" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></symbol>
|
||||
<symbol id="icon-refresh" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></symbol>
|
||||
<symbol id="icon-settings" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></symbol>
|
||||
<symbol id="icon-at" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" /></symbol>
|
||||
<symbol id="icon-mail" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></symbol>
|
||||
<symbol id="icon-house" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" /></symbol>
|
||||
<symbol id="icon-phone" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" /></symbol>
|
||||
<symbol id="icon-mobile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></symbol>
|
||||
<symbol id="icon-calendar" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" /></symbol>
|
||||
<symbol id="icon-hand" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M10.05 4.575a1.575 1.575 0 10-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 013.15 0v1.5m-3.15 0l.075 5.925m3.075.75V4.575m0 0a1.575 1.575 0 013.15 0V15M6.9 7.575a1.575 1.575 0 10-3.15 0v8.175a6.75 6.75 0 006.75 6.75h2.018a5.25 5.25 0 003.712-1.538l1.732-1.732a5.25 5.25 0 001.538-3.712l.003-2.024a.668.668 0 01.198-.471 1.575 1.575 0 10-2.228-2.228 3.818 3.818 0 00-1.12 2.687M6.9 7.575V12m6.27 4.318A4.49 4.49 0 0116.35 15m.002 0h-.002" /></symbol>
|
||||
<symbol id="icon-copy" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6" /></symbol>
|
||||
<symbol id="icon-photo" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></symbol>
|
||||
<symbol id="icon-print" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="icon-locked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></symbol>
|
||||
<symbol id="icon-unlocked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></symbol>
|
||||
<symbol id="icon-external-link" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z"/></symbol>
|
||||
<symbol id="icon-cookie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21.1634 10.9711C21.5132 10.8204 21.9579 11.0073 21.9811 11.3875C22.1494 14.1467 21.1794 16.9625 19.071 19.0709C15.1657 22.976 8.83405 22.9761 4.92886 19.0709C1.02367 15.1657 1.02377 8.83405 4.92885 4.9288C7.03704 2.82061 9.8525 1.85015 12.6115 2.01806C12.9916 2.04119 13.1787 2.48598 13.028 2.83576C12.4774 4.11367 12.7239 5.65275 13.7677 6.69657C14.3307 7.25963 15.038 7.58971 15.7706 7.68848C16.0483 7.72594 16.2738 7.95144 16.3113 8.2292C16.4101 8.96178 16.7402 9.66905 17.3032 10.2321C18.347 11.2759 19.8857 11.5218 21.1634 10.9711ZM19.7845 13.8397C19.8558 13.5362 19.6123 13.2603 19.3017 13.2337C18.0569 13.1271 16.8417 12.599 15.889 11.6463C15.3166 11.0739 14.8979 10.4061 14.6309 9.69455C14.5744 9.54422 14.4556 9.42534 14.3052 9.36892C13.5936 9.10186 12.9259 8.68322 12.3535 8.11078C11.4005 7.15778 10.8715 5.94208 10.7648 4.69683C10.7381 4.38618 10.4621 4.14264 10.1586 4.2141C8.76039 4.54327 7.43341 5.25267 6.34306 6.34301C3.21903 9.46721 3.21893 14.5326 6.34307 17.6567C9.46721 20.7809 14.5326 20.7808 17.6568 17.6567C18.7474 16.566 19.4557 15.2384 19.7845 13.8397ZM9.8786 14.1212C10.4644 14.707 10.4644 15.6567 9.8786 16.2425C9.29281 16.8283 8.34307 16.8283 7.75728 16.2425C7.17149 15.6567 7.17149 14.707 7.75728 14.1212C8.34306 13.5354 9.29281 13.5354 9.8786 14.1212ZM10.2321 8.11078C10.8179 8.69657 10.8179 9.64631 10.2321 10.2321C9.64636 10.8179 8.69661 10.8179 8.11083 10.2321C7.52504 9.64631 7.52504 8.69656 8.11083 8.11078C8.69661 7.52499 9.64636 7.52499 10.2321 8.11078ZM15.889 13.7676C16.4748 14.3534 16.4748 15.3032 15.889 15.889C15.3032 16.4747 14.3535 16.4747 13.7677 15.889C13.1819 15.3032 13.1819 14.3534 13.7677 13.7676C14.3535 13.1818 15.3032 13.1818 15.889 13.7676Z"/></symbol>
|
||||
<symbol id="icon-mask" fill="currentColor" stroke="none" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M288 64C39.52 64 0 182.1 0 273.5C0 379.5 78.8 448 176 448c27.33 0 51.21-6.516 66.11-36.79l19.93-40.5C268.3 358.6 278.1 352.4 288 352.1c9.9 .3711 19.7 6.501 25.97 18.63l19.93 40.5C348.8 441.5 372.7 448 400 448c97.2 0 176-68.51 176-174.5C576 182.1 536.5 64 288 64zM400 400c-18.12 0-19.56-2.924-23.04-9.986l-20.35-41.33c-13.87-26.86-38.85-43.53-66.82-44.57L288 304L286.2 304.1c-27.96 1.049-52.94 17.71-67.24 45.41l-19.93 40.49C195.6 397.1 194.1 400 176 400c-75.36 0-128-52.04-128-126.5C48 229.3 48 112 288 112s240 117.3 240 161.5C528 347.1 475.4 400 400 400zM160 192C124.7 192 96 220.7 96 256s28.65 64 64 64c35.35 0 64-28.65 64-64S195.3 192 160 192zM416 192c-35.35 0-64 28.65-64 64s28.65 64 64 64c35.35 0 64-28.65 64-64S451.3 192 416 192z"/></symbol>
|
||||
<symbol id="icon-excel" fill="currentColor" stroke="none" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM229.1 233.3L192 280.9L154.9 233.3C146.8 222.8 131.8 220.9 121.3 229.1C110.8 237.2 108.9 252.3 117.1 262.8L161.6 320l-44.53 57.25c-8.156 10.47-6.25 25.56 4.188 33.69C125.7 414.3 130.8 416 135.1 416c7.156 0 14.25-3.188 18.97-9.25L192 359.1l37.06 47.65C233.8 412.8 240.9 416 248 416c5.125 0 10.31-1.656 14.72-5.062c10.44-8.125 12.34-23.22 4.188-33.69L222.4 320l44.53-57.25c8.156-10.47 6.25-25.56-4.188-33.69C252.2 220.9 237.2 222.8 229.1 233.3z"/></symbol>
|
||||
<symbol id="icon-pdf" fill="currentColor" stroke="none" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM202 286.1c.877-2.688 1.74-5.398 2.582-8.145c1.434-5.762 7.488-31.54 7.488-52.47C212.1 207 197.1 192 178.6 192C160.1 192 145.1 207 145.1 225.5c0 .2969 .1641 28.81 13.85 62.3c-7.035 19.36-15.57 38.8-25.41 57.93c-21.49 10.11-39.24 22.23-52.8 36.07c-6.234 6.438-9.367 14.74-9.367 24.72c0 18.45 15.01 33.46 33.46 33.46c10.8 0 20.98-5.227 27.22-13.98c7.322-10.28 18.38-26.9 30.47-48.95c15.8-6.352 33.88-11.72 53.88-16c13.55 9.578 28.9 17.29 45.71 22.95c4.527 1.551 9.402 2.348 14.43 2.348c20.26 0 36.13-16.19 36.13-36.86c0-20.33-16.54-36.87-36.87-36.87h-3.705c-2.727 .125-20.51 1.141-45.37 5.367C216.9 308.9 208.6 298.3 202 286.1zM110.2 410.4c-3.273 4.688-12.03 2.777-12.03-5.312c0-1.754 .6289-3.43 1.729-4.555c9.02-9.219 19.94-17.05 31.85-23.72C122.3 393.1 114.3 404.7 110.2 410.4zM178.6 218.8c3.693 0 6.703 3.008 6.703 6.703c0 15.21-4.109 34.84-5.746 42.1C172.1 245 171.9 227.2 171.9 225.5C171.9 221.8 174.9 218.8 178.6 218.8zM162.3 348.3c6.611-13.48 13.22-28.46 19.38-44.7c6.389 10.92 14.56 21.86 24.96 31.97C192.6 338.8 177.4 342.9 162.3 348.3zM272.4 339.5h3.352c5.539 0 10.05 4.5 10.05 10.79c0 5.129-4.176 9.32-9.32 9.32c-2.029 0-4.059-.3164-5.852-.9414c-12.33-4.137-23.11-9.32-32.54-15.19C258.3 340.3 272.1 339.5 272.4 339.5z"/></symbol>
|
||||
<symbol id="icon-file" fill="currentColor" stroke="none" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M365.3 93.38l-74.63-74.64C278.6 6.743 262.3 0 245.4 0L64-.0001c-35.35 0-64 28.65-64 64l.0065 384c0 35.35 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM320 464H64.02c-8.836 0-15.1-7.163-16-15.1L48 64.13c-.0004-8.837 7.163-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1v288C336 456.8 328.8 464 320 464z"/></symbol>
|
||||
<symbol id="icon-sigma" fill="currentColor" stroke="none" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M335.1 136l.0005-56H83.59l165 158.7C253.3 243.2 255.1 249.5 255.1 256s-2.656 12.78-7.375 17.31L83.59 432h252.4l-.0005-56c0-13.25 10.75-24 24-24C373.2 352 384 362.8 384 376v80c0 13.25-10.75 24-24 24H23.99c-9.782 0-18.59-5.938-22.25-15.03s-1.438-19.47 5.625-26.28L197.4 256L7.364 73.31C.3015 66.5-1.917 56.13 1.739 47.03S14.21 32 23.99 32h336C373.2 32 384 42.75 384 56v80C384 149.3 373.2 160 359.1 160C346.7 160 335.1 149.3 335.1 136z"/></symbol>
|
||||
<symbol id="icon-percent" fill="currentColor" stroke="none" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M376.1 71.03c-9.375-9.375-24.56-9.375-33.94 0l-336 336c-9.375 9.375-9.375 24.56 0 33.94C11.72 445.7 17.84 448 24 448s12.28-2.344 16.97-7.031l336-336C386.3 95.59 386.3 80.41 376.1 71.03zM64 176c26.51 0 48-21.49 48-48S90.51 80 64 80C37.49 80 16 101.5 16 128S37.49 176 64 176zM320 336c-26.51 0-48 21.49-48 48s21.49 48 48 48c26.51 0 48-21.49 48-48S346.5 336 320 336z"/></symbol>
|
||||
<symbol id="icon-chair" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M288 16c26.5 0 48 21.5 48 48V256h16V64c0-35.3-28.7-64-64-64H160C124.7 0 96 28.7 96 64V256h16V64c0-26.5 21.5-48 48-48H288zM48 168c0-4.4-3.6-8-8-8s-8 3.6-8 8v80c0 4.4 3.6 8 8 8s8-3.6 8-8V168zm368 0c0-4.4-3.6-8-8-8s-8 3.6-8 8v80c0 4.4 3.6 8 8 8s8-3.6 8-8V168zM80 304H368c6.1 0 11.6 3.4 14.3 8.8l16 32c2.5 5 2.2 10.9-.7 15.6s-8.1 7.6-13.6 7.6H224 64c-5.5 0-10.7-2.9-13.6-7.6s-3.2-10.6-.7-15.6l16-32c2.7-5.4 8.3-8.8 14.3-8.8zm152 80H384c11.1 0 21.4-5.7 27.2-15.2s6.4-21.2 1.4-31.1l-16-32C391.2 294.8 380.1 288 368 288H80c-12.1 0-23.2 6.8-28.6 17.7l-16 32c-5 9.9-4.4 21.7 1.4 31.1S52.9 384 64 384H216v64H184c-30.9 0-56 25.1-56 56c0 4.4 3.6 8 8 8s8-3.6 8-8c0-22.1 17.9-40 40-40h32v40c0 4.4 3.6 8 8 8s8-3.6 8-8V464h32c22.1 0 40 17.9 40 40c0 4.4 3.6 8 8 8s8-3.6 8-8c0-30.9-25.1-56-56-56H232V384z"/></symbol>
|
||||
<symbol id="icon-eye" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></symbol>
|
||||
<symbol id="icon-profile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" /></symbol>
|
||||
<symbol id="icon-chart" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" /></symbol>
|
||||
<symbol id="icon-info" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></symbol>
|
||||
<symbol id="icon-star" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /></symbol>
|
||||
<symbol id="icon-cancel" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></symbol>
|
||||
<symbol id="icon-bell" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" /></symbol>
|
||||
<symbol id="icon-folder" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="icon-power" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" /></symbol>
|
||||
<symbol id="icon-no-symbol" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" /></symbol>
|
||||
<symbol id="icon-draggable" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z"/></symbol>
|
||||
<symbol id="icon-discount" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.0544 2.0941C11.1756 1.13856 12.8248 1.13855 13.9461 2.09411L15.2941 3.24286C15.4542 3.37935 15.6533 3.46182 15.8631 3.47856L17.6286 3.61945C19.0971 3.73663 20.2633 4.9028 20.3805 6.37131L20.5214 8.13679C20.5381 8.34654 20.6205 8.54568 20.757 8.70585L21.9058 10.0539C22.8614 11.1751 22.8614 12.8243 21.9058 13.9456L20.757 15.2935C20.6206 15.4537 20.538 15.6529 20.5213 15.8627L20.3805 17.6281C20.2633 19.0967 19.0971 20.2628 17.6286 20.3799L15.8631 20.5208C15.6533 20.5376 15.4542 20.6201 15.2941 20.7566L13.9461 21.9053C12.8248 22.8609 11.1756 22.8608 10.0543 21.9053L8.70631 20.7566C8.54615 20.6201 8.34705 20.5376 8.1373 20.5209L6.37184 20.3799C4.9033 20.2627 3.73716 19.0966 3.61997 17.6281L3.47906 15.8627C3.46232 15.6529 3.37983 15.4538 3.24336 15.2936L2.0946 13.9455C1.13905 12.8243 1.13904 11.1752 2.09458 10.0539L3.24334 8.70589C3.37983 8.54573 3.46234 8.34654 3.47907 8.13678L3.61996 6.3713C3.73714 4.90278 4.90327 3.73665 6.3718 3.61946L8.13729 3.47857C8.34705 3.46183 8.54619 3.37935 8.70636 3.24286L10.0544 2.0941ZM12.6488 3.61632C12.2751 3.29782 11.7253 3.29781 11.3516 3.61632L10.0036 4.76509C9.5231 5.17456 8.92568 5.42201 8.29637 5.47223L6.5309 5.61312C6.04139 5.65219 5.65268 6.04089 5.61362 6.53041L5.47272 8.29593C5.4225 8.92521 5.17505 9.52259 4.76559 10.0031L3.61683 11.3511C3.29832 11.7248 3.29831 12.2746 3.61683 12.6483L4.76559 13.9963C5.17506 14.4768 5.4225 15.0743 5.47275 15.7035L5.61363 17.469C5.65268 17.9585 6.04139 18.3473 6.53092 18.3863L8.29636 18.5272C8.92563 18.5774 9.5231 18.8249 10.0036 19.2344L11.3516 20.3831C11.7254 20.7016 12.2751 20.7016 12.6488 20.3831L13.9969 19.2343C14.4773 18.8249 15.0747 18.5774 15.704 18.5272L17.4695 18.3863C17.959 18.3472 18.3478 17.9585 18.3868 17.469L18.5277 15.7035C18.5779 15.0742 18.8253 14.4768 19.2349 13.9964L20.3836 12.6483C20.7022 12.2746 20.7021 11.7249 20.3836 11.3511L19.2348 10.0031C18.8253 9.52259 18.5779 8.92519 18.5277 8.2959L18.3868 6.53041C18.3478 6.0409 17.959 5.65219 17.4695 5.61312L15.704 5.47224C15.0748 5.42203 14.4773 5.17455 13.9968 4.76508L12.6488 3.61632ZM14.8284 7.75718L16.2426 9.1714L9.17154 16.2425L7.75733 14.8282L14.8284 7.75718ZM10.2322 10.232C9.64641 10.8178 8.69667 10.8178 8.11088 10.232C7.52509 9.6463 7.52509 8.69652 8.11088 8.11073C8.69667 7.52494 9.64641 7.52494 10.2322 8.11073C10.818 8.69652 10.818 9.6463 10.2322 10.232ZM13.7677 15.8889C14.3535 16.4747 15.3032 16.4747 15.889 15.8889C16.4748 15.3031 16.4748 14.3534 15.889 13.7676C15.3032 13.1818 14.3535 13.1818 13.7677 13.7676C13.1819 14.3534 13.1819 15.3031 13.7677 15.8889Z"/></symbol>
|
||||
<symbol id="icon-stack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.0833 15.1999L21.2854 15.9212C21.5221 16.0633 21.5989 16.3704 21.4569 16.6072C21.4146 16.6776 21.3557 16.7365 21.2854 16.7787L12.5144 22.0412C12.1977 22.2313 11.8021 22.2313 11.4854 22.0412L2.71451 16.7787C2.47772 16.6366 2.40093 16.3295 2.54301 16.0927C2.58523 16.0223 2.64413 15.9634 2.71451 15.9212L3.9166 15.1999L11.9999 20.0499L20.0833 15.1999ZM20.0833 10.4999L21.2854 11.2212C21.5221 11.3633 21.5989 11.6704 21.4569 11.9072C21.4146 11.9776 21.3557 12.0365 21.2854 12.0787L11.9999 17.6499L2.71451 12.0787C2.47772 11.9366 2.40093 11.6295 2.54301 11.3927C2.58523 11.3223 2.64413 11.2634 2.71451 11.2212L3.9166 10.4999L11.9999 15.3499L20.0833 10.4999ZM12.5144 1.30864L21.2854 6.5712C21.5221 6.71327 21.5989 7.0204 21.4569 7.25719C21.4146 7.32757 21.3557 7.38647 21.2854 7.42869L11.9999 12.9999L2.71451 7.42869C2.47772 7.28662 2.40093 6.97949 2.54301 6.7427C2.58523 6.67232 2.64413 6.61343 2.71451 6.5712L11.4854 1.30864C11.8021 1.11864 12.1977 1.11864 12.5144 1.30864ZM11.9999 3.33233L5.88723 6.99995L11.9999 10.6676L18.1126 6.99995L11.9999 3.33233Z"/></symbol>
|
||||
<symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M18 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8" /><path d="M16 5l1.5 7l4.5 0" /><path d="M2 10l15 0" /><path d="M7 5l0 5" /><path d="M12 5l0 5" /></symbol>
|
||||
<symbol id="icon-ski" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M380.7 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM2.7 268.9c6.1-11.8 20.6-16.3 32.4-10.2L239.8 365l49.6-74.5-82.7-87.9c-9.5-10.1-14.4-22.4-15.1-34.9l87.7 42 49.4 52.5c12.8 13.6 14.5 34.1 4.2 49.6l-50.2 75.4 135.8 70.5c13.6 7.1 29.8 7.2 43.6 .3l15.2-7.6c11.9-5.9 26.3-1.1 32.2 10.7s1.1 26.3-10.7 32.2l-15.2 7.6c-27.5 13.7-59.9 13.5-87.2-.7L12.9 301.3C1.2 295.2-3.4 280.7 2.7 268.9zM118.9 65.6L137 74.2l8.7-17.4c4-7.9 13.6-11.1 21.5-7.2s11.1 13.6 7.2 21.5l-8.5 16.9 55.5 26.6c1.7-.9 3.5-1.7 5.4-2.5l80.9-32.4c32.4-13 68.6 6.5 75.6 40.7l12.3 59.7 62.9 30.1c12 5.7 17 20 11.3 32s-20 17-32 11.3l-54.2-25.9c-1-.3-1.9-.6-2.8-1l-229.1-110-9.2 18.4c-4 7.9-13.6 11.1-21.5 7.2s-11.1-13.6-7.2-21.5l9-18-17.6-8.4c-8-3.8-11.3-13.4-7.5-21.3s13.4-11.3 21.3-7.5zm217.3 64.7c-1-4.9-6.2-7.7-10.8-5.8l-45.7 18.3 65.5 31.5-9-43.9z"/></symbol>
|
||||
<symbol id="icon-snowboard" fill="currentColor" stroke="nonw" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M204.3 3c11.6-6.4 26.2-2.3 32.6 9.3l31.9 57.4c1.3 2.3 3.6 3.8 6.2 4.1l33.8 3.4c24.1 2.4 46.9 12 65.4 27.6L503.5 213.6c10.1 8.5 11.4 23.7 2.9 33.8s-23.7 11.4-33.8 2.9l-68.2-57.5-82.3 43.9 24.7 18.9c24.9 19 34.6 51.9 24.1 81.4l-28.2 79c-1.4 3.8-3.6 7-6.3 9.6l93.3 35.7c4.6 1.8 9.4 2.6 14.3 2.6H472c13.3 0 24 10.7 24 24s-10.7 24-24 24H443.8c-10.8 0-21.4-2-31.5-5.8L60.1 371.3c-11.5-4.4-22-11.2-30.8-20L7 329c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l22.4 22.4c4 4 8.7 7.1 14 9.1l19.4 7.4c-2.9-11.4 2.9-23.4 14-28.1L192 272l0-68.2c0-21.2 12-40.6 31-50.1L284.4 123l-14.1-1.4c-18.3-1.8-34.5-12.5-43.4-28.5L195 35.7c-6.4-11.6-2.3-26.2 9.3-32.6zm91.8 407.2c-.3-3.4 .1-6.9 1.3-10.3l28.2-79c3.5-9.8 .3-20.8-8-27.1L240 234.4l0 42.9c0 16.1-9.7 30.7-24.6 36.9L134 348.2l162.1 62.1zM259.6 189l20.5 15.7 84.6-45.1-17.2-14.5L259.6 189zM384 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0z"/></symbol>
|
||||
<symbol id="icon-hourglass" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/></symbol>
|
||||
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
|
||||
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
|
||||
<symbol id="icon-backpack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M96,48h64a48,48,0,0,1,48,48V216a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V96A48,48,0,0,1,96,48Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M80,224V152a16,16,0,0,1,16-16h64a16,16,0,0,1,16,16v72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,48V32a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="88" x2="144" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="168" x2="176" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="144" y1="168" x2="144" y2="184" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-barricade" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="24" y="72" width="208" height="88" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="84" y1="72" x2="172" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="156" y1="72" x2="232" y2="148" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="84" x2="100" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="192" y1="160" x2="192" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="64" y1="160" x2="64" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-bed" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M112,168V80H216a32,32,0,0,1,32,32v56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="208" x2="24" y2="48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="24 168 248 168 248 208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="80" x2="24" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-boot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="152" y1="120" x2="112" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="88" x2="112" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M240,168v32a8,8,0,0,1-8,8H205.89a8.08,8.08,0,0,1-3.58-.84l-12.62-6.32a8.08,8.08,0,0,0-3.58-.84H169.89a8.08,8.08,0,0,0-3.58.84l-12.62,6.32a8.08,8.08,0,0,1-3.58.84H121.89a8.08,8.08,0,0,1-3.58-.84l-12.62-6.32a8.08,8.08,0,0,0-3.58-.84H85.89a8.08,8.08,0,0,0-3.58.84l-12.62,6.32a8.08,8.08,0,0,1-3.58.84H40a8,8,0,0,1-8-8V168c9.22-32.06,12-88.65,0-120H144a8,8,0,0,1,8,8v64h40A48,48,0,0,1,240,168Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="32" y1="168" x2="240" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-lift" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="16" y1="72" x2="240" y2="32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="40" y="104" width="176" height="112" rx="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="168" x2="216" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="52" x2="128" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="160" y1="104" x2="160" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="96" y1="104" x2="96" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-chat" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M71.58,144,32,176V48a8,8,0,0,1,8-8H168a8,8,0,0,1,8,8v88a8,8,0,0,1-8,8Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M80,144v40a8,8,0,0,0,8,8h96.42L224,224V96a8,8,0,0,0-8-8H176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-weather" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="87.66" y1="56.73" x2="83.5" y2="33.09" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="56.69" y1="76.46" x2="37.03" y2="62.69" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48.73" y1="112.31" x2="25.09" y2="116.48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="123.52" y1="64.69" x2="137.28" y2="45.03" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,144a68.06,68.06,0,1,1,68,72H84a44,44,0,1,1,14.2-85.66" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M59.65,135.35a48,48,0,1,1,80.19-50.94" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-party" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M40.49,205.52,93,61.14a7.79,7.79,0,0,1,12.84-2.85l91.88,91.88A7.79,7.79,0,0,1,194.86,163L50.48,215.51A7.79,7.79,0,0,1,40.49,205.52Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M168,72s0-24,24-24,24-24,24-24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="144" y1="16" x2="144" y2="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="216" y1="112" x2="232" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="216" y1="80" x2="240" y2="72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="78.09" y1="102.09" x2="153.91" y2="177.91" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="101.11" y1="197.11" x2="58.89" y2="154.89" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-dice" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><rect width="256" height="256" fill="none"/><rect x="40" y="40" width="176" height="176" rx="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="92" cy="84" r="12"/><circle cx="164" cy="84" r="12"/><circle cx="92" cy="128" r="12"/><circle cx="164" cy="128" r="12"/><circle cx="92" cy="172" r="12"/><circle cx="164" cy="172" r="12"/></symbol>
|
||||
<symbol id="icon-flag" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="48" y1="224" x2="48" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M48,176c64-55.43,112,55.43,176,0V56C160,111.43,112,.57,48,56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-burger" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M48.07,96a8,8,0,0,1-7.89-9.72C46.68,55.55,83.52,32,128,32s81.32,23.55,87.82,54.28A8,8,0,0,1,207.93,96Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="24 176 68 160 108 176 148 160 188 176 232 160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="24" y1="128" x2="232" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M208,168.73V184a32,32,0,0,1-32,32H80a32,32,0,0,1-32-32V167.27" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-handshake" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="200 152 160 192 96 176 40 136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="72.68 70.63 128 56 183.32 70.63" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M34.37,60.42,8.85,111.48a8,8,0,0,0,3.57,10.73L40,136,72.68,70.63,45.11,56.85A8,8,0,0,0,34.37,60.42Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,136l27.58-13.79a8,8,0,0,0,3.57-10.73L221.63,60.42a8,8,0,0,0-10.74-3.57L183.32,70.63Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,72H144L98.34,116.29a8,8,0,0,0,1.38,12.42C117.23,139.9,141,139.13,160,120l40,32,16-16" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="124.06 216 82.34 205.57 56 186.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-badge" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="152" y1="112" x2="192" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="144" x2="192" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="96" cy="120" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M64,168c3.55-13.8,17.09-24,32-24s28.46,10.19,32,24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-parking" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M104,144h32a28,28,0,0,0,0-56H104v80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-drinks" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M56,32H200L176.85,225A8,8,0,0,1,168.9,232H87.1A8,8,0,0,1,79.15,225Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="60.8" y1="72" x2="195.2" y2="72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-sauna" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M153.599 76.8c-35.285 0-64 28.706-64 64s28.715 64 64 64 64-28.715 64-64-28.706-64-64-64Zm0 102.4c-21.205 0-38.4-17.195-38.4-38.4s17.195-38.4 38.4-38.4 38.4 17.195 38.4 38.4-17.194 38.4-38.4 38.4Zm152.824 315.623-51.2-140.8c-1.835-5.06-6.639-8.422-12.023-8.422h-89.6V230.4c0-7.074-5.726-12.8-12.8-12.8S128 223.326 128 230.4v128c0 7.074 5.726 12.8 12.8 12.8h93.44l48.137 132.378C284.263 508.775 289.178 512 294.4 512c1.451 0 2.927-.247 4.378-.776 6.638-2.415 10.077-9.754 7.645-16.401ZM209.919 396.8h-107.52c-7.074 0-12.8 5.726-12.8 12.8s5.726 12.8 12.8 12.8h107.52c7.074 0 12.8-5.726 12.8-12.8s-5.726-12.8-12.8-12.8ZM340.36 7.074c-3.157-6.323-10.863-8.875-17.169-5.726-6.323 3.166-8.892 10.846-5.726 17.178 22.886 45.79 12.228 85.726-.111 131.959-7.603 28.424-15.454 57.839-15.991 90.914-.111 7.074 5.521 12.902 12.587 13.013h.213c6.972 0 12.689-5.598 12.8-12.587.486-29.935 7.927-57.796 15.113-84.736 12.552-46.975 25.514-95.547-1.716-150.015Zm65.98 0c-3.174-6.323-10.837-8.875-17.178-5.726-6.323 3.166-8.892 10.846-5.726 17.178 22.903 45.79 12.237 85.726-.111 131.977-13.141 49.203-28.041 104.977-.324 174.251 1.997 5.009 6.81 8.047 11.887 8.047 1.587 0 3.2-.29 4.753-.913 6.562-2.628 9.762-10.078 7.134-16.64-24.559-61.389-11.426-110.575 1.271-158.148C420.599 110.123 433.578 61.551 406.34 7.074Z"/></symbol>
|
||||
<symbol id="icon-shower" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M64,236a12,12,0,1,1-12-12A12,12,0,0,1,64,236Zm20-44a12,12,0,1,0,12,12A12,12,0,0,0,84,192Zm-64,0a12,12,0,1,0,12,12A12,12,0,0,0,20,192Zm32-32a12,12,0,1,0,12,12A12,12,0,0,0,52,160ZM256,40a8,8,0,0,1-8,8H219.31L191.46,75.86,169.8,202.65a16,16,0,0,1-27.09,8.66l-98-98a16,16,0,0,1,8.69-27.1L180.14,64.54l30.2-30.2A8,8,0,0,1,216,32h32A8,8,0,0,1,256,40ZM174.21,81.79,56,102l98,98Z"/></symbol>
|
||||
<symbol id="icon-wifi" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><rect width="256" height="256" fill="none"/><circle cx="128" cy="204" r="12"/><path d="M232,93.19a164,164,0,0,0-208,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M200,129a116,116,0,0,0-144,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M168,165a68,68,0,0,0-80,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-mountain" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="164" cy="52" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M8,200,81.1,75.94a8,8,0,0,1,13.8,0L168,200Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="50.35" y1="128" x2="125.65" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M146.61,163.71l33.06-55.79a8,8,0,0,1,13.76,0L248,200H168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"><!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL --><g fill="none" fill-rule="evenodd" stroke-width="2"><circle cx="22" cy="22" r="1"><animate attributeName="r" begin="0s" dur="1.8s" values="1; 20" calcMode="spline" keyTimes="0; 1" keySplines="0.165, 0.84, 0.44, 1" repeatCount="indefinite" /><animate attributeName="stroke-opacity" begin="0s" dur="1.8s" values="1; 0" calcMode="spline" keyTimes="0; 1" keySplines="0.3, 0.61, 0.355, 1" repeatCount="indefinite" /></circle><circle cx="22" cy="22" r="1"><animate attributeName="r" begin="-0.9s" dur="1.8s" values="1; 20" calcMode="spline" keyTimes="0; 1" keySplines="0.165, 0.84, 0.44, 1" repeatCount="indefinite" /><animate attributeName="stroke-opacity" begin="-0.9s" dur="1.8s" values="1; 0" calcMode="spline" keyTimes="0; 1" keySplines="0.3, 0.61, 0.355, 1" repeatCount="indefinite" /></circle></g></symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 700 490" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a"><image height="490" transform="matrix(.00288044 .00008847 .00008847 -.00288044 -.5862 .835135)" width="700" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAArwAAAHqCAAAAADD7sLoAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGsElEQVR4nO3dQW7bRgBA0SHTm/VGvWCPVQRIqy4cSVHkBKRlmvzWewsDjClybPxMZiJZnk4Dmua9BwBvJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXrD/2HsDDTt8+5j7Tp7jFytv8u90o3kE/3r//XHzqfP4wzXdH03Q+msYY8/lofDn/0TT9eDRP46WC6f4xrxxdHvzaCTe3H/PN7W9PuB3MK1/O/Wiv15tuTvjVaOfx/dR5GuOfvxZ/b/dg2UCWeMkSL1niPZiVm7Zp3WOWnflBG8eH9Tds7+kt5Sw78737upy5xYXHZX93bGZessRLlnjJsub92YrlKfsy85IlXrLES5Z4f2/nBbD19+/YsP3ayucg3v3Zq+2eMll34cMy85IlXrLES9bTrXlXLE+3HcPCU8fuoz0uMy9Z4iVLvGSJd6m918n73v6Qnm7D9iZHKGeLTd4Rvq4HmHnJEi9Z4iXrmde8B3jhy3aLziOMYWtm3t35b4y3Ei9Z4iVLvGQ984ZtjOSrtoJD3oiZlyzxkiVesp59zXuxyZuQLj917ZtMbXLh2krazLulVg2t0Q7xEiZessRLlg3b1rbZCTLMvISJlyzxkiXeBx3hZyE2u+bBF+E2bHcO8JPjOz/dV3nhmpmXLPGSJV6ynmXNu/KXTG/3+6i38Kzv92vm/TjbRvNpklxOvGSJl6xnWfOepjHGaXz/cLr8G/vfOP+aivNqcJrHuK4Nbz/38nd9/vHo+rnp/jHXS033J9xe4XqZMcZ8c/TQ+F45GsvGN43xdfk3eA/PEu9Ls2O85MqnYNlAlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVesv4HAfsUn4ytnvgAAAAASUVORK5CYII="/></mask><linearGradient id="b" gradientTransform="matrix(346.842 10.6534 -10.6534 346.842 194.415 296.129)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#bcbbba"/><stop offset="1" stop-color="#565553"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#38a548"/><stop offset=".04" stop-color="#38a548"/><stop offset=".91" stop-color="#a3c639"/><stop offset="1" stop-color="#a3c639"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.6449" x2="329.4659" xlink:href="#c" y1="294.368" y2="294.368"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="446.802" x2="605.544" xlink:href="#c" y1="267.649" y2="267.649"/><linearGradient id="f" gradientTransform="matrix(170.152 5.22629 -5.22629 170.152 451.782 264.535)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#118ece"/><stop offset=".04" stop-color="#118ece"/><stop offset=".91" stop-color="#184050"/><stop offset="1" stop-color="#184050"/></linearGradient><g mask="url(#a)"><path d="m194.119 305.842c82.487-5.378 144.251 2.12 145.365 2.304 103.692 17.276 164.073 19.425 201.218 16.582l.165-5.09c-26.441 1.313-62.179-2.767-105.949-8.079-34.114-4.14-128.221-34.151-239.888-35.469z" fill="url(#b)" fill-rule="nonzero"/></g><g fill-rule="nonzero"><path d="m197.896 191.813 65.607-48.765 16.602 4.882 27.895-31.182 35.653 48.047 25.115 9.5 64.998 70.249-69.739-55.007-15.345 5.156-37.617-47.693-17.036 17.035-25.195-4.806-30.27 32.225.755-19.807z" fill="#194050"/><path d="m38.408 306.474c-6.228 20.417-10.765 29.685-10.765 29.685 114.575-92.78 253.335-76.5 301.822-68.124-20.416-6.28-53.109-14.042-93.153-15.321-2.838-.095-5.695-.137-8.575-.137-63.667 0-137.636 22.097-189.329 53.897z" fill="url(#d)"/><path d="m602.405 251.387c-3.773 14.368-15.048 26.054-33.505 34.746-23.254 10.95-65.423 7.092-122.097-11.058 56.626 21.98 99.929 28.75 123.31 19.387 17.943-7.186 29.194-17.413 33.425-30.402 3.003-9.222 2.252-18.934.255-27.32.269 4.797-.095 9.741-1.388 14.647z" fill="url(#e)"/><path d="m605.397 211.094-6.403 4.315c.18.265 18.079 27.414 10.685 52.708-4.169 14.255-15.827 25.648-34.642 33.868-24.444 10.667-68.738 5.138-128.108-16.001-45.857-16.337-84.494-36.357-84.815-36.527-Line truncated
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 700 490" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a"><image height="490" transform="matrix(.00288044 .00008847 .00008847 -.00288044 -.5862 .835135)" width="700" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAArwAAAHqCAAAAADD7sLoAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGsElEQVR4nO3dQW7bRgBA0SHTm/VGvWCPVQRIqy4cSVHkBKRlmvzWewsDjClybPxMZiJZnk4Dmua9BwBvJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXrD/2HsDDTt8+5j7Tp7jFytv8u90o3kE/3r//XHzqfP4wzXdH03Q+msYY8/lofDn/0TT9eDRP46WC6f4xrxxdHvzaCTe3H/PN7W9PuB3MK1/O/Wiv15tuTvjVaOfx/dR5GuOfvxZ/b/dg2UCWeMkSL1niPZiVm7Zp3WOWnflBG8eH9Tds7+kt5Sw78737upy5xYXHZX93bGZessRLlnjJsub92YrlKfsy85IlXrLES5Z4f2/nBbD19+/YsP3ayucg3v3Zq+2eMll34cMy85IlXrLES9bTrXlXLE+3HcPCU8fuoz0uMy9Z4iVLvGSJd6m918n73v6Qnm7D9iZHKGeLTd4Rvq4HmHnJEi9Z4iXrmde8B3jhy3aLziOMYWtm3t35b4y3Ei9Z4iVLvGQ984ZtjOSrtoJD3oiZlyzxkiVesp59zXuxyZuQLj917ZtMbXLh2krazLulVg2t0Q7xEiZessRLlg3b1rbZCTLMvISJlyzxkiXeBx3hZyE2u+bBF+E2bHcO8JPjOz/dV3nhmpmXLPGSJV6ynmXNu/KXTG/3+6i38Kzv92vm/TjbRvNpklxOvGSJl6xnWfOepjHGaXz/cLr8G/vfOP+aivNqcJrHuK4Nbz/38nd9/vHo+rnp/jHXS033J9xe4XqZMcZ8c/TQ+F45GsvGN43xdfk3eA/PEu9Ls2O85MqnYNlAlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVesv4HAfsUn4ytnvgAAAAASUVORK5CYII="/></mask><linearGradient id="b" gradientTransform="matrix(346.842 10.6534 -10.6534 346.842 194.415 296.129)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#bcbbba"/><stop offset="1" stop-color="#565553"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#38a548"/><stop offset=".04" stop-color="#38a548"/><stop offset=".91" stop-color="#a3c639"/><stop offset="1" stop-color="#a3c639"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.6449" x2="329.4659" xlink:href="#c" y1="294.368" y2="294.368"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="446.802" x2="605.544" xlink:href="#c" y1="267.649" y2="267.649"/><linearGradient id="f" gradientTransform="matrix(170.152 5.22629 -5.22629 170.152 451.782 264.535)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#118ece"/><stop offset=".04" stop-color="#118ece"/><stop offset=".91" stop-color="#184050"/><stop offset="1" stop-color="#184050"/></linearGradient><g mask="url(#a)"><path d="m194.119 305.842c82.487-5.378 144.251 2.12 145.365 2.304 103.692 17.276 164.073 19.425 201.218 16.582l.165-5.09c-26.441 1.313-62.179-2.767-105.949-8.079-34.114-4.14-128.221-34.151-239.888-35.469z" fill="url(#b)" fill-rule="nonzero"/></g><g fill-rule="nonzero"><path d="m197.896 191.813 65.607-48.765 16.602 4.882 27.895-31.182 35.653 48.047 25.115 9.5 64.998 70.249-69.739-55.007-15.345 5.156-37.617-47.693-17.036 17.035-25.195-4.806-30.27 32.225.755-19.807z" fill="#194050"/><path d="m38.408 306.474c-6.228 20.417-10.765 29.685-10.765 29.685 114.575-92.78 253.335-76.5 301.822-68.124-20.416-6.28-53.109-14.042-93.153-15.321-2.838-.095-5.695-.137-8.575-.137-63.667 0-137.636 22.097-189.329 53.897z" fill="url(#d)"/><path d="m602.405 251.387c-3.773 14.368-15.048 26.054-33.505 34.746-23.254 10.95-65.423 7.092-122.097-11.058 56.626 21.98 99.929 28.75 123.31 19.387 17.943-7.186 29.194-17.413 33.425-30.402 3.003-9.222 2.252-18.934.255-27.32.269 4.797-.095 9.741-1.388 14.647z" fill="url(#e)"/><path d="m605.397 211.094-6.403 4.315c.18.265 18.079 27.414 10.685 52.708-4.169 14.255-15.827 25.648-34.642 33.868-24.444 10.667-68.738 5.138-128.108-16.001-45.857-16.337-84.494-36.357-84.815-36.527-Line truncated
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
@@ -5,8 +5,6 @@ const DEBOUNCE_MS = 200
|
||||
|
||||
let debounceTimeout = null
|
||||
let historyRestoreGracePeriod = false
|
||||
let savedScrollPosition = null
|
||||
let activeRequests = 0
|
||||
|
||||
function getIndicator() {
|
||||
return document.getElementById(INDICATOR_ID)
|
||||
@@ -30,20 +28,12 @@ function hide() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleBeforeRequest(event) {
|
||||
function handleBeforeRequest() {
|
||||
// Ignore requests triggered during history restore grace period
|
||||
if (true === historyRestoreGracePeriod) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRequests++
|
||||
|
||||
// Save scroll position of the target container before HTMX request
|
||||
const target = event.detail?.target || document.getElementById('main-content')
|
||||
if (target && typeof target.scrollTop === 'number') {
|
||||
savedScrollPosition = { element: target, top: target.scrollTop }
|
||||
}
|
||||
|
||||
// Don't start a new debounce if already pending
|
||||
if (debounceTimeout) {
|
||||
return
|
||||
@@ -55,29 +45,7 @@ function handleBeforeRequest(event) {
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function handleRequestEnd() {
|
||||
// Counter may already be 0 if a force-reset (history save, page show) fired
|
||||
// before this afterRequest — ignore the stale completion in that case
|
||||
if (0 === activeRequests) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRequests--
|
||||
if (0 === activeRequests) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAfterSwap() {
|
||||
// Restore scroll position after HTMX swaps content
|
||||
if (savedScrollPosition !== null && savedScrollPosition.element) {
|
||||
savedScrollPosition.element.scrollTop = savedScrollPosition.top
|
||||
savedScrollPosition = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleHistoryRestore() {
|
||||
activeRequests = 0
|
||||
hide()
|
||||
|
||||
// Set grace period to ignore change events triggered by history restore
|
||||
@@ -88,29 +56,23 @@ function handleHistoryRestore() {
|
||||
}
|
||||
|
||||
function handleTimeout() {
|
||||
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
|
||||
// and handles the counter. This handler only shows the alert.
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuche es erneut. Falls das Problem weiterhin besteht, kontaktiere bitte unseren Support.')
|
||||
hide()
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
|
||||
}
|
||||
|
||||
function handleBeforeHistorySave() {
|
||||
// Hide indicator before HTMX saves the page to history cache
|
||||
activeRequests = 0
|
||||
hide()
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
activeRequests = 0
|
||||
hide()
|
||||
}
|
||||
|
||||
// Initialize event listeners
|
||||
document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest)
|
||||
document.body.addEventListener('htmx:afterRequest', handleRequestEnd)
|
||||
document.body.addEventListener('htmx:afterSwap', handleAfterSwap)
|
||||
document.body.addEventListener('htmx:afterRequest', hide)
|
||||
document.body.addEventListener('htmx:timeout', handleTimeout)
|
||||
document.body.addEventListener('htmx:historyRestore', handleHistoryRestore)
|
||||
document.body.addEventListener('htmx:sendError', hide)
|
||||
document.body.addEventListener('htmx:responseError', hide)
|
||||
document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave)
|
||||
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
window.addEventListener('pageshow', hide)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
@@ -1,29 +0,0 @@
|
||||
export const useFetch = controller => {
|
||||
Object.assign(controller, {
|
||||
checkStatus(response) {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response
|
||||
} else {
|
||||
const error = new Error(response.statusText)
|
||||
error.response = response
|
||||
throw error
|
||||
}
|
||||
},
|
||||
parseJSON(response) {
|
||||
return response.json()
|
||||
},
|
||||
parseHTML(response) {
|
||||
return response.text()
|
||||
},
|
||||
getInitObject(method = 'get', body = null) {
|
||||
return {
|
||||
method,
|
||||
body,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,10 +53,6 @@
|
||||
background-image: url('../images/bg_5.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);;
|
||||
}
|
||||
|
||||
.bg-outer--summer {
|
||||
background-image: url('../images/bg_summer.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);;
|
||||
}
|
||||
|
||||
.sbw .bg-outer--2 {
|
||||
background-image: url('../images/bg_7.jpg'), linear-gradient(190deg, rgba(24, 82, 123, 1) 0%, rgba(22, 88, 131, 1) 62%, rgba(0, 112, 224, 1) 100%);;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
@import "./components/button.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/gallery.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/price_calendar.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/typography.css";
|
||||
@import "./components/rte-content.css";
|
||||
@import "components/typography.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/button.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/pagination.css";
|
||||
@@ -1,10 +0,0 @@
|
||||
@import "./components/autocomplete.css";
|
||||
@import "./components/badge.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/datatable.css";
|
||||
@import "./components/datepicker.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/typography.css";
|
||||
@@ -1,66 +0,0 @@
|
||||
/* Shared by app.css and admin.css, imported right after tailwindcss */
|
||||
@source "../../templates";
|
||||
@source "../../src";
|
||||
@source "../../assets";
|
||||
@source inline("rotate-90");
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@theme {
|
||||
--font-sans: Lato, sans-serif;
|
||||
|
||||
--color-primary: #3d8ccb;
|
||||
--color-secondary: #f9c700;
|
||||
--color-primary-bg: #ebf3fa;
|
||||
--color-primary-light: #0070e0;
|
||||
--color-primary-medium: #165883;
|
||||
--color-primary-dark: #18527b;
|
||||
--color-pink: #fa1a8c;
|
||||
|
||||
--color-sbw-primary: #B3820F;
|
||||
--color-sbw-secondary: #E91C26;
|
||||
--color-sbw-primary-dark: #8D7207;
|
||||
--color-sbw-overlay: #c20000;
|
||||
|
||||
--color-snz-primary: #82c3c6;
|
||||
--color-snz-secondary: #f9c33d;
|
||||
--color-snz-primary-dark: #82c3c6;
|
||||
|
||||
--color-ser-primary: #006fb6;
|
||||
--color-ser-secondary: #DE2C1B;
|
||||
--color-ser-primary-light: #04A4CC;
|
||||
|
||||
--background-image-badge-gradient: linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%);
|
||||
--background-image-brand-gradient: linear-gradient(to top right, #009fff, #165883, #18527b);
|
||||
}
|
||||
|
||||
/*
|
||||
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
|
||||
* and buttons had a pointer cursor.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
|
||||
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
|
||||
* Safari keeps its own overlay scrollbars.
|
||||
*/
|
||||
@layer base {
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-gray-300) transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Reserve the scrollbar gutter so content does not shift width when a
|
||||
scrollbar appears (e.g. when a collapsible section is opened). */
|
||||
@utility scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components_admin.css" layer(components);
|
||||
@@ -1,5 +1,7 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components.css" layer(components);
|
||||
@import "./_utilities.css";
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components.css";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@@ -1,33 +0,0 @@
|
||||
.autocomplete {
|
||||
@apply relative w-full;
|
||||
}
|
||||
|
||||
.autocomplete[data-loading="true"]:after {
|
||||
@apply block absolute top-0 right-0;
|
||||
@apply absolute w-4 h-4 rounded-full right-0 mr-4;
|
||||
content: "";
|
||||
border: 3px solid rgba(0, 0, 0, 0.12);
|
||||
border-right: 3px solid rgba(0, 0, 0, 0.48);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
animation: rotate 1s infinite linear;
|
||||
}
|
||||
|
||||
.autocomplete-icon {
|
||||
@apply absolute top-1/2 right-0 transform -translate-y-1/2 mr-4;
|
||||
}
|
||||
.autocomplete[data-loading="true"] .autocomplete-icon {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.autocomplete-result-list {
|
||||
@apply bg-white shadow-md max-h-64 overflow-y-auto;
|
||||
}
|
||||
|
||||
.autocomplete-result {
|
||||
@apply p-2;
|
||||
}
|
||||
.autocomplete-result:hover,
|
||||
.autocomplete-result[aria-selected="true"] {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap;
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
.button {
|
||||
/* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
|
||||
The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
|
||||
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
|
||||
@apply uppercase text-center text-sm/6 md:text-base/6;
|
||||
@apply transition-colors outline-hidden focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
||||
@apply inline-flex items-center justify-center space-x-2 h-10 leading-10 px-4 md:px-8 cursor-pointer;
|
||||
@apply uppercase leading-none text-center;
|
||||
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light;
|
||||
@apply rounded-md;
|
||||
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
|
||||
min-height: calc(1lh + 2 * --spacing(2) + 2px);
|
||||
}
|
||||
|
||||
.button--small {
|
||||
@apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
|
||||
min-height: calc(1lh + 2 * --spacing(1) + 2px);
|
||||
@apply px-2 py-1 md:px-4 text-xs sm:text-sm;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
/* Only the image: the background shorthand would reset .button's background-origin */
|
||||
background-image: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
|
||||
background: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
|
||||
@apply text-white;
|
||||
@apply hover:bg-none hover:bg-pink/80;
|
||||
}
|
||||
@@ -26,52 +20,42 @@
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-primary-light text-white;
|
||||
@apply hover:bg-primary-light/50;
|
||||
}
|
||||
|
||||
.button--neutral {
|
||||
@apply bg-none bg-gray-200;
|
||||
@apply hover:bg-gray-200/50;
|
||||
}
|
||||
|
||||
.button--warning {
|
||||
@apply bg-none bg-red-500 text-white;
|
||||
@apply hover:bg-red-500/50;
|
||||
@apply bg-none bg-primary-dark text-white;
|
||||
@apply hover:bg-primary-light/80;
|
||||
}
|
||||
|
||||
.sbw {
|
||||
.button--primary {
|
||||
@apply bg-none bg-sbw-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/50;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-sbw-primary-dark text-white;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/50;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/80;
|
||||
}
|
||||
}
|
||||
|
||||
.snz {
|
||||
.button--primary {
|
||||
@apply bg-none bg-snz-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-snz-primary/50;
|
||||
@apply hover:bg-none hover:bg-snz-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-snz-primary-dark text-white;
|
||||
@apply hover:bg-none hover:bg-snz-primary/50;
|
||||
@apply hover:bg-none hover:bg-snz-primary/80;
|
||||
}
|
||||
}
|
||||
|
||||
.ser {
|
||||
.button--primary {
|
||||
@apply bg-none bg-ser-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-ser-primary/50;
|
||||
@apply hover:bg-none hover:bg-ser-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-ser-primary text-white;
|
||||
@apply hover:bg-none hover:bg-ser-primary/50;
|
||||
@apply hover:bg-none hover:bg-ser-primary/80;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.data-table-wrapper {
|
||||
@apply overflow-x-auto lg:overflow-x-visible mb-8;
|
||||
}
|
||||
|
||||
.data-table-wrapper__inner {
|
||||
@apply align-middle inline-block min-w-full overflow-hidden lg:overflow-visible;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@apply min-w-full;
|
||||
}
|
||||
|
||||
.data-table tbody {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
@apply border border-gray-300;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply px-4 py-2 bg-gray-200 text-left leading-4 font-bold text-primary align-top;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply px-4 py-2 leading-5 align-top;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
@apply hover:bg-gray-100;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@import "vanilla-calendar-pro/styles/index.css";
|
||||
@import "vanilla-calendar-pro/styles/themes/light.css";
|
||||
|
||||
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn,
|
||||
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "glightbox/dist/css/glightbox.min.css";
|
||||
@@ -1,41 +0,0 @@
|
||||
.pc-day {
|
||||
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded-sm text-white cursor-pointer;
|
||||
}
|
||||
|
||||
.pc-day--out-of-month {
|
||||
@apply pointer-events-none opacity-0;
|
||||
}
|
||||
|
||||
.pc-day[data-status="ok"] {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
|
||||
.pc-day[data-status="blocked-to-ok"] {
|
||||
background: linear-gradient(to bottom right, var(--color-gray-400) 50%, var(--color-emerald-500) 50%);
|
||||
}
|
||||
|
||||
.pc-day[data-status="checkout-only"] {
|
||||
background: linear-gradient(to bottom right, var(--color-emerald-500) 50%, var(--color-gray-400) 50%);
|
||||
}
|
||||
|
||||
.pc-day[data-status="past"] {
|
||||
@apply bg-transparent text-gray-400 cursor-default;
|
||||
}
|
||||
|
||||
.pc-day[data-status="blocked"] {
|
||||
@apply bg-gray-400 cursor-default;
|
||||
}
|
||||
|
||||
.pc-day[data-status=""], .pc-day:not([data-status]) {
|
||||
@apply text-gray-400 cursor-default;
|
||||
}
|
||||
|
||||
.pc-day.pc-day--pending-start,
|
||||
.pc-day.pc-day--selected-start,
|
||||
.pc-day.pc-day--selected-end {
|
||||
@apply bg-none outline-hidden bg-secondary text-gray-700;
|
||||
}
|
||||
|
||||
.pc-day.pc-day--in-range {
|
||||
@apply bg-none bg-secondary text-gray-700 opacity-55;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.rte-content {
|
||||
p {
|
||||
@apply pb-4;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc p-4;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal p-4;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
@import "toastify-js/src/toastify.css";
|
||||
|
||||
.toastify {
|
||||
@apply shadow-lg rounded-sm;
|
||||
@apply shadow-lg rounded;
|
||||
}
|
||||
|
||||
.toastify--success {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-emerald-600);
|
||||
background: theme('colors.emerald.600');
|
||||
}
|
||||
|
||||
.toastify--warning {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-red-600);
|
||||
background: theme('colors.red.600');
|
||||
}
|
||||
|
||||
.toastify--error {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-red-700);
|
||||
background: theme('colors.red.700');
|
||||
}
|
||||
|
||||
.toastify--info {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-primary);
|
||||
background: theme('colors.primary');
|
||||
}
|
||||
@@ -1,71 +1,71 @@
|
||||
@import "tippy.js/dist/tippy.css";
|
||||
|
||||
.tippy-box {
|
||||
background-color: var(--color-primary);
|
||||
background-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-primary);
|
||||
border-top-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-primary);
|
||||
border-bottom-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-primary);
|
||||
border-left-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-primary);
|
||||
border-right-color: theme('colors.primary');
|
||||
}
|
||||
|
||||
.sbw {
|
||||
.tippy-box {
|
||||
background-color: var(--color-sbw-secondary);
|
||||
background-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-sbw-secondary);
|
||||
border-top-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-sbw-secondary);
|
||||
border-bottom-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-sbw-secondary);
|
||||
border-left-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-sbw-secondary);
|
||||
border-right-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
}
|
||||
|
||||
.snz {
|
||||
.tippy-box {
|
||||
background-color: var(--color-snz-primary-dark);
|
||||
background-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-snz-primary-dark);
|
||||
border-top-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-snz-primary-dark);
|
||||
border-bottom-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-snz-primary-dark);
|
||||
border-left-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-snz-primary-dark);
|
||||
border-right-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
}
|
||||
|
||||
.ser {
|
||||
.tippy-box {
|
||||
background-color: var(--color-ser-secondary);
|
||||
background-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-ser-secondary);
|
||||
border-top-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-ser-secondary);
|
||||
border-bottom-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-ser-secondary);
|
||||
border-left-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-ser-secondary);
|
||||
border-right-color: theme('colors.ser.secondary');
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 134 KiB |
@@ -1,4 +0,0 @@
|
||||
### list custom fields
|
||||
GET {{clickup_baseurl}}/list/{{clickup_list_id}}/field?include_applied_objects=true
|
||||
Authorization: {{clickup_api_token}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.5",
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
@@ -14,61 +14,50 @@
|
||||
"doctrine/doctrine-bundle": "^2.13",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^3.3",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"flagception/flagception-bundle": "^6.1",
|
||||
"knplabs/knp-menu-bundle": "^3.7",
|
||||
"knplabs/knp-paginator-bundle": "^6.10",
|
||||
"league/flysystem-bundle": "^3.4",
|
||||
"league/flysystem-sftp-v3": "^3.29",
|
||||
"league/html-to-markdown": "^5.1",
|
||||
"league/oauth2-server-bundle": "^1.0",
|
||||
"maennchen/zipstream-php": "^3.2",
|
||||
"nelexa/zip": "^4.0",
|
||||
"nesbot/carbon": "^3.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpoffice/phpspreadsheet": "^5.4",
|
||||
"phpstan/phpdoc-parser": "^2.0",
|
||||
"spatie/crypto": "^2.1",
|
||||
"symfony/apache-pack": "^1.0",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dom-crawler": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/asset": "6.4.*",
|
||||
"symfony/console": "6.4.*",
|
||||
"symfony/doctrine-messenger": "6.4.*",
|
||||
"symfony/dom-crawler": "6.4.*",
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"symfony/expression-language": "6.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/html-sanitizer": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/lock": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mcp-bundle": "^0.14.1",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/form": "6.4.*",
|
||||
"symfony/framework-bundle": "6.4.*",
|
||||
"symfony/html-sanitizer": "6.4.*",
|
||||
"symfony/http-client": "6.4.*",
|
||||
"symfony/intl": "6.4.*",
|
||||
"symfony/lock": "6.4.*",
|
||||
"symfony/mailer": "6.4.*",
|
||||
"symfony/mime": "6.4.*",
|
||||
"symfony/monolog-bundle": "^3.0",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/notifier": "6.4.*",
|
||||
"symfony/process": "6.4.*",
|
||||
"symfony/property-access": "6.4.*",
|
||||
"symfony/property-info": "6.4.*",
|
||||
"symfony/runtime": "6.4.*",
|
||||
"symfony/security-bundle": "6.4.*",
|
||||
"symfony/serializer": "6.4.*",
|
||||
"symfony/stimulus-bundle": "^2.21",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/ux-twig-component": "^2.36",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/string": "6.4.*",
|
||||
"symfony/translation": "6.4.*",
|
||||
"symfony/twig-bundle": "6.4.*",
|
||||
"symfony/uid": "6.4.*",
|
||||
"symfony/validator": "6.4.*",
|
||||
"symfony/web-link": "6.4.*",
|
||||
"symfony/webpack-encore-bundle": "^2.2",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"symfony/yaml": "6.4.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/html-extra": "^3.15",
|
||||
"twig/intl-extra": "*",
|
||||
"twig/string-extra": "^3.24",
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"zenstruck/schedule-bundle": "^1.8"
|
||||
},
|
||||
@@ -80,7 +69,7 @@
|
||||
},
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.5.0"
|
||||
"php": "8.2.28"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -103,8 +92,6 @@
|
||||
"symfony/polyfill-php81": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"rector": "vendor/bin/rector process",
|
||||
"rector:dry-run": "vendor/bin/rector process --dry-run",
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
@@ -122,24 +109,22 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": true,
|
||||
"require": "7.4.*",
|
||||
"require": "6.4.*",
|
||||
"docker": true
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"deployer/deployer": "^7.5",
|
||||
"friendsofphp/php-cs-fixer": "^3.75",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/phpunit": "^12",
|
||||
"rector/rector": "^2.4",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"spatie/ray": "^1.43",
|
||||
"spatie/x-ray": "^1.2",
|
||||
"symfony/browser-kit": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/debug-bundle": "6.4.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/phpunit-bridge": "^7.4",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
"symfony/phpunit-bridge": "^7.1",
|
||||
"symfony/stopwatch": "6.4.*",
|
||||
"symfony/web-profiler-bundle": "6.4.*"
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,4 @@ return [
|
||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
|
||||
Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true],
|
||||
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
||||
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -15,7 +15,5 @@ framework:
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
pools:
|
||||
bpn.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
tags: true
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
@@ -9,7 +9,8 @@ doctrine:
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
enable_native_lazy_objects: true
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
@@ -32,6 +33,8 @@ when@test:
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
flagception:
|
||||
features: {}
|
||||
@@ -1,18 +1,21 @@
|
||||
flysystem:
|
||||
storages:
|
||||
xml_export.storage:
|
||||
local:
|
||||
directory: '%kernel.project_dir%/var/xmlexport'
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
||||
xml_source.storage:
|
||||
sftp:
|
||||
host: '%env(SFTP_XML_SOURCE_HOST)%'
|
||||
port: '%env(int:SFTP_XML_SOURCE_PORT)%'
|
||||
username: '%env(SFTP_XML_SOURCE_USER)%'
|
||||
password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
|
||||
root: '%env(SFTP_XML_SOURCE_ROOT)%'
|
||||
adapter: 'sftp'
|
||||
options:
|
||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
||||
xml_dump.storage:
|
||||
local:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%kernel.project_dir%/var/bpn'
|
||||
json_export.storage:
|
||||
local:
|
||||
directory: '%kernel.project_dir%/var/jsonexport'
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%env(resolve:JSON_EXPORT_PATH)%'
|
||||
@@ -1,6 +1,7 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
annotations: false
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
|
||||
@@ -8,28 +9,21 @@ framework:
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
# cookie_samesite: lax
|
||||
cookie_samesite: none # this is to make login work in staging environment. to be removed when going live!
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
rate_limiter:
|
||||
# Per MCP client. A chatbot turn makes a few tool calls; this catches runaway loops.
|
||||
chatbot_mcp:
|
||||
policy: sliding_window
|
||||
limit: 60
|
||||
interval: '1 minute'
|
||||
|
||||
http_client:
|
||||
scoped_clients:
|
||||
typo3.client:
|
||||
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
|
||||
headers:
|
||||
accept: 'text/json'
|
||||
X-Api-Key: '%env(APP_CMS_API_KEY)%'
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
@@ -1,3 +0,0 @@
|
||||
knp_menu:
|
||||
twig:
|
||||
template: _partials/_menu.html.twig
|
||||
@@ -1,11 +0,0 @@
|
||||
knp_paginator:
|
||||
default_options:
|
||||
page_name: page
|
||||
sort_field_name: sort
|
||||
sort_direction_name: direction
|
||||
distinct: true
|
||||
filter_field_name: filterField
|
||||
filter_value_name: filterValue
|
||||
template:
|
||||
pagination: '_partials/_paginator_sliding.html.twig'
|
||||
sortable: '_partials/_paginator_sortable_link.html.twig'
|
||||
@@ -3,12 +3,10 @@ league_oauth2_server:
|
||||
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
||||
private_key_passphrase: null
|
||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||
require_code_challenge_for_public_clients: true
|
||||
enable_client_credentials_grant: true
|
||||
enable_auth_code_grant: true
|
||||
enable_refresh_token_grant: true
|
||||
enable_refresh_token_grant: false
|
||||
enable_password_grant: false
|
||||
enable_implicit_grant: false
|
||||
access_token_ttl: PT10M
|
||||
resource_server:
|
||||
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
|
||||
@@ -17,11 +15,6 @@ league_oauth2_server:
|
||||
default: ['email']
|
||||
persistence:
|
||||
doctrine: null
|
||||
client:
|
||||
allow_plaintext_secrets: false
|
||||
# App\Entity\OAuth2Client adds the required_roles column that
|
||||
# App\EventListener\AuthorizationCodeListener gates authorization on.
|
||||
classname: App\Entity\OAuth2Client
|
||||
|
||||
when@test:
|
||||
league_oauth2_server:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# The website chatbot's MCP server, called by the n8n AI Agent through its MCP Client Tool
|
||||
# node. Read-only tools over the mirrored TYPO3 content and our own trip data; see
|
||||
# docs/chatbot-mcp.md. Authentication is the "mcp" firewall in security.yaml.
|
||||
mcp:
|
||||
servers:
|
||||
chatbot:
|
||||
name: 'MyE&P chatbot'
|
||||
version: '1.0.0'
|
||||
instructions: 'Answers questions about the trips and website content of E&P Reisen and its brands. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. The structured trip fields of search_trips and get_trip (dates, rooms, prices, pickups, inclusions) come from the booking system and override anything editorial text says about them: product descriptions, FAQs and search_content/get_document results can be out of date.'
|
||||
http:
|
||||
path: /mcp
|
||||
# DNS rebinding protection guards unauthenticated local servers. This one is
|
||||
# public, behind a bearer token, and never called from a browser.
|
||||
allowed_hosts: false
|
||||
registry:
|
||||
tools: ['App\Chatbot\Mcp\Tool\']
|
||||
# The same trip tools without the website's editorial text: no search_content and
|
||||
# get_document, and a get_trip that leaves out the product and hotel page texts.
|
||||
# Same tokens as /mcp; a client picks the variant by the path it is pointed at.
|
||||
chatbot_trips:
|
||||
name: 'MyE&P chatbot (trips only)'
|
||||
version: '1.0.0'
|
||||
instructions: 'Answers questions about the trips of E&P Reisen and its brands: dates, rooms, prices, availability, pickups and inclusions, all from the booking system. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. Website texts such as descriptions, FAQs and terms are not available here: for anything the tools do not answer, refer the user to the url of the trip.'
|
||||
http:
|
||||
path: /mcp/trips
|
||||
allowed_hosts: false
|
||||
registry:
|
||||
tools:
|
||||
- 'App\Chatbot\Mcp\Tool\SearchTripsTool'
|
||||
- 'App\Chatbot\Mcp\TripsOnly\'
|
||||
@@ -13,7 +13,7 @@ framework:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
# sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
@@ -21,12 +21,9 @@ framework:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\MailjetNewsletterEventMessage: async
|
||||
App\Message\CreateClickUpBookingTaskMessage: async
|
||||
App\Message\RoleNominationMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
@@ -4,8 +4,6 @@ monolog:
|
||||
- bpn
|
||||
- core
|
||||
- auth
|
||||
- admin
|
||||
- chatbot
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
@@ -29,21 +27,11 @@ when@dev:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.core.log"
|
||||
level: debug
|
||||
channels: ["core"]
|
||||
admin:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: debug
|
||||
channels: ["admin"]
|
||||
chatbot:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||
level: debug
|
||||
channels: ["chatbot"]
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
|
||||
channels: ["!event", "!bpn", "!core", "!auth"]
|
||||
# uncomment to get logging in your browser
|
||||
# you may have to allow bigger header sizes in your Web server configuration
|
||||
#firephp:
|
||||
@@ -71,7 +59,7 @@ when@test:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod: &prod_config
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
database:
|
||||
@@ -96,26 +84,13 @@ when@prod: &prod_config
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.core.log"
|
||||
level: info
|
||||
channels: ["core"]
|
||||
admin:
|
||||
type: rotating_file
|
||||
max_files: 7
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: info
|
||||
channels: ["admin"]
|
||||
# MCP tool calls, file only: the log for judging search quality later.
|
||||
chatbot:
|
||||
type: rotating_file
|
||||
max_files: 30
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||
level: info
|
||||
channels: ["chatbot"]
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
channels: ["!bpn", "!core", "!auth", "!admin", "!chatbot"]
|
||||
channels: ["!bpn", "!core", "!auth"]
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
|
||||
@@ -130,5 +105,3 @@ when@prod: &prod_config
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
|
||||
when@staging: *prod_config
|
||||
@@ -1,3 +0,0 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -2,12 +2,9 @@ framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# How URLs are generated where there is no request to take the host from: scheduled
|
||||
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||
# Without it those links would point at http://localhost. Only ever consulted in a
|
||||
# non-HTTP context, so web requests keep using their own host — which matters here,
|
||||
# since one codebase serves four brands.
|
||||
default_uri: '%env(APP_BASE_URL)%'
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
security:
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
providers:
|
||||
@@ -9,27 +8,6 @@ security:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: email
|
||||
mailjet_webhook_provider:
|
||||
memory:
|
||||
users:
|
||||
mailjet:
|
||||
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||
# Never consulted: McpAccessTokenHandler builds the user from the client name its
|
||||
# token belongs to. Named only because a firewall must have a provider.
|
||||
mcp_provider:
|
||||
memory:
|
||||
users: []
|
||||
|
||||
role_hierarchy:
|
||||
# ROLE_CUSTOMER_EXPERT gates the customer-service surfaces (booking drafts, travel
|
||||
# mailing). Inheriting it here is what keeps them open to administrators: the booking
|
||||
# drafts used to be ROLE_ADMIN only before their #[IsGranted] moved to the narrower role.
|
||||
ROLE_ADMIN:
|
||||
- ROLE_GROUPS_ADMIN
|
||||
- ROLE_CUSTOMER_EXPERT
|
||||
ROLE_GROUPS_ADMIN:
|
||||
- ROLE_GROUPS_MANAGER
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
@@ -41,21 +19,7 @@ security:
|
||||
pattern: ^/api
|
||||
security: true
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
oauth2: true
|
||||
mailjet_webhook:
|
||||
pattern: ^/webhooks/mailjet/newsletter$
|
||||
security: true
|
||||
stateless: true
|
||||
provider: mailjet_webhook_provider
|
||||
http_basic:
|
||||
realm: 'Mailjet Webhook'
|
||||
mcp:
|
||||
pattern: ^/mcp(/trips)?$
|
||||
stateless: true
|
||||
provider: mcp_provider
|
||||
access_token:
|
||||
token_handler: App\Security\McpAccessTokenHandler
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -68,10 +32,8 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
|
||||
- { path: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
|
||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
@@ -80,7 +42,6 @@ when@test:
|
||||
# important to generate secure password hashes. In tests however, secure hashes
|
||||
# are not important, waste resources and increase test times. The following
|
||||
# reduces the work factor to the lowest possible values.
|
||||
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4 # Lowest possible value for bcrypt
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
form_themes: ['forms.html.twig']
|
||||
paths:
|
||||
'%kernel.project_dir%/assets/images': images
|
||||
globals:
|
||||
htmx_change_trigger: 'change delay:100ms'
|
||||
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
|
||||
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
|
||||
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
|
||||
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
|
||||
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
|
||||
privacy_policy_url: '%env(APP_PRIVACY_POLICY_URL)%'
|
||||
insurance_revocation_url: '%env(APP_INSURANCE_REVOCATION_URL)%'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
twig_component:
|
||||
anonymous_template_directory: 'components/'
|
||||
defaults:
|
||||
# Namespace & directory for components
|
||||
App\Twig\Components\: 'components/'
|
||||
@@ -14,6 +14,4 @@ when@test:
|
||||
intercept_redirects: false
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect: false
|
||||
collect_serializer_data: true
|
||||
profiler: { collect: false }
|
||||
@@ -3,79 +3,27 @@ zenstruck_schedule:
|
||||
|
||||
mailer:
|
||||
service: mailer
|
||||
default_to: [email protected]
|
||||
default_from: noreply@ep-reisen.de
|
||||
default_to: [email protected]
|
||||
default_from: info@ep-reisen.de
|
||||
subject_prefix: "[MyE&P]"
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
to: [email protected]
|
||||
|
||||
tasks:
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: "0 1 * * *"
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "*/15 8-19 * * *"
|
||||
description: "Sync BusPro XML data every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "0 20-23,0-7 * * *"
|
||||
description: "Sync BusPro XML data hourly outside peak hours"
|
||||
|
||||
- task: app:cleanup:log-entries
|
||||
frequency: "15 1 * * *"
|
||||
description: "Removes outdated log entries with a retention period of 6 months"
|
||||
|
||||
- task: app:draft:cleanup
|
||||
frequency: "30 1 * * *"
|
||||
description: "Removes expired drafts"
|
||||
|
||||
- task: app:cleanup:newsletter-opt-in-requests
|
||||
frequency: "45 1 * * *"
|
||||
description: "Removes expired pending newsletter double opt-in requests"
|
||||
|
||||
- task: league:oauth2-server:clear-expired-tokens
|
||||
frequency: "0 * * * *"
|
||||
description: "Removes expired oauth2 tokens hourly"
|
||||
|
||||
- task: app:bpn:refresh-travel-snapshot --refresh-after 60
|
||||
frequency: "10 8-19 * * *"
|
||||
description: "Syncs travel data snapshots with BusPro XML data hourly during peak hours"
|
||||
|
||||
- task: app:bpn:refresh-travel-snapshot --refresh-after 180
|
||||
frequency: "10 20-23,0-7 * * *"
|
||||
description: "Syncs travel data snapshots with BusPro XML data every 3 hours outside peak hours"
|
||||
|
||||
when@staging:
|
||||
zenstruck_schedule:
|
||||
mailer:
|
||||
subject_prefix: "[MyE&P STAGING]"
|
||||
|
||||
when@prod:
|
||||
zenstruck_schedule:
|
||||
mailer:
|
||||
subject_prefix: "[MyE&P PROD]"
|
||||
|
||||
tasks:
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "*/15 8-19 * * *"
|
||||
description: "Syncs BusPro XML data every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "0 20-23,0-7 * * *"
|
||||
description: "Syncs BusPro XML data hourly outside peak hours"
|
||||
|
||||
- task: app:bpn:sync-contingents
|
||||
frequency: "5-59/15 8-19 * * *"
|
||||
description: "Syncs the full contingent horizon every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:sync-contingents
|
||||
frequency: "5 20-23,0-7 * * *"
|
||||
description: "Syncs the full contingent horizon hourly outside peak hours"
|
||||
|
||||
- task: app:content:sync
|
||||
frequency: "0 * * * *"
|
||||
description: "Mirrors changes in the TYPO3 content feed for the chatbot MCP server"
|
||||
|
||||
- task: app:content:sync --refresh
|
||||
frequency: "5 1 * * 0"
|
||||
description: "Refetches TYPO3 content stored more than 7 days ago, within the night window"
|
||||
|
||||
- task: app:content:check-freshness --max-age 150
|
||||
frequency: "10 * * * *"
|
||||
description: "Fails when the TYPO3 content mirror has missed more than one hourly sync"
|
||||
description: "Removes outdated log entries with a retention period of 6 months"
|
||||
@@ -1,3 +0,0 @@
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
@@ -1,4 +1,3 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
|
||||
@@ -6,122 +5,28 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%'
|
||||
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
|
||||
path_to_keys: '%kernel.project_dir%/config/secret'
|
||||
bpn_debug: '%env(APP_BPN_DEBUG)%'
|
||||
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
|
||||
newsletter_confirmation_ttl_hours: '%env(int:APP_NEWSLETTER_CONFIRMATION_TTL_HOURS)%'
|
||||
travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%'
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
||||
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
|
||||
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
|
||||
# reading it once the last date has passed.
|
||||
legacy_promo_voucher_codes:
|
||||
BEWERTUNG25: '2026-10-31'
|
||||
ANNA50: '2026-12-20'
|
||||
SIDNEY50: '2026-12-20'
|
||||
LENA20: '2027-03-04'
|
||||
BOTSCHAFTER2627: '2027-04-30'
|
||||
|
||||
# Named travel periods of the website search ("Reisezeitraum"), offered by the chatbot's
|
||||
# search_trips. Duplicated from the TYPO3 search configuration on purpose: update both each
|
||||
# season. Periods may overlap; one whose end has passed is no longer offered.
|
||||
chatbot_travel_periods:
|
||||
- { label: 'Vorsaison', from: '2026-11-01', to: '2026-12-20' }
|
||||
- { label: 'um Weihnachten', from: '2026-12-20', to: '2026-12-28' }
|
||||
- { label: 'Silvester', from: '2026-12-26', to: '2027-01-03' }
|
||||
- { label: '1 Januarwoche', from: '2027-01-02', to: '2027-01-09' }
|
||||
- { label: 'Nachsaison', from: '2027-03-20', to: '2027-05-01' }
|
||||
- { label: 'Ostern', from: '2027-03-20', to: '2027-04-03' }
|
||||
|
||||
# MailJet list ids and their labels
|
||||
mailjet_lists:
|
||||
10321569: 'E&P Newsletter'
|
||||
10321382: 'Reisen-Alert Ahrntal'
|
||||
10321383: 'Reisen-Alert Davos'
|
||||
10321391: 'Reisen-Alert Familienreisen'
|
||||
10321384: 'Reisen-Alert Arosa-Lenzerheide'
|
||||
10321385: 'Reisen-Alert Les Deux Alpes'
|
||||
10321386: 'Reisen-Alert Montafon'
|
||||
10321387: 'Reisen-Alert Portes du Soleil'
|
||||
10321388: 'Reisen-Alert Saalbach-Hinterglemm'
|
||||
10538809: 'Reisen-Alert Scuol'
|
||||
10321389: 'Reisen-Alert Stubaital'
|
||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||
|
||||
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
|
||||
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
|
||||
# domain part, never as a suffix.
|
||||
employee_email_domains:
|
||||
- 'ep-reisen.de'
|
||||
|
||||
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
|
||||
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
|
||||
# code from everyone holding it, one login at a time, with manual re-approval per user.
|
||||
# Entries for houses that are not in use yet stay commented out.
|
||||
bpn_crm_house_manager_ids:
|
||||
1299: 'SSL'
|
||||
1300: 'SST'
|
||||
1301: 'MVK'
|
||||
# 1302: 'ASB'
|
||||
1303: 'LPJ'
|
||||
1304: 'DKS'
|
||||
1305: 'DGS'
|
||||
1306: 'DPW'
|
||||
1307: 'DKI'
|
||||
1308: 'DWW'
|
||||
1309: 'PMV'
|
||||
# 1352: 'ASG'
|
||||
1371: 'ASC'
|
||||
1373: 'PCJ'
|
||||
1374: 'SBW'
|
||||
# 1375: 'UCH'
|
||||
# 1376: 'SZO'
|
||||
1377: 'KHH'
|
||||
1459: 'SVS'
|
||||
1461: 'SHM'
|
||||
1462: 'AGR'
|
||||
|
||||
# MailJet contact metadata names for name synchronization
|
||||
mailjet_contact_metadata_fields:
|
||||
firstName: 'vorname'
|
||||
lastName: 'nachname'
|
||||
|
||||
# Body dimensions ranges for BodyDimensionsType and ParticipantValidator
|
||||
body_dimension_ranges:
|
||||
height_min: 145
|
||||
height_max: 210
|
||||
weight_min: 40
|
||||
weight_max: 120
|
||||
shoe_size_min: 35
|
||||
shoe_size_max: 50
|
||||
|
||||
# Season data
|
||||
season_winter_from: '%env(APP_SEASON_WINTER_FROM)%'
|
||||
season_summer_from: '%env(APP_SEASON_SUMMER_FROM)%'
|
||||
|
||||
# domain mapping for theme, gtm id and cmp url
|
||||
domain_config:
|
||||
ep-reisen.de:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
ski-boarderweek.de:
|
||||
theme: sbw
|
||||
gtm_id: GTM-MS2C8CH
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
|
||||
semesterende-skireisen.de:
|
||||
theme: ser
|
||||
gtm_id: GTM-MNKDKL9
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
|
||||
snowzone.net:
|
||||
theme: snz
|
||||
gtm_id: GTM-M96CKL
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
|
||||
# Body dimensions choices for BodyDimensionsType
|
||||
body_dimensions.height_choices:
|
||||
'bis 148cm': '-148'
|
||||
'149 - 157cm': '149-157'
|
||||
'158 - 166cm': '158-166'
|
||||
'167 - 178cm': '167-178'
|
||||
'179 - 185cm': '179-185'
|
||||
'186 - 194cm': '186-194'
|
||||
'195cm oder mehr': '195cm+'
|
||||
body_dimensions.shoe_size_min: 36
|
||||
body_dimensions.shoe_size_max: 48
|
||||
body_dimensions.weight_choices:
|
||||
'42 - 48kg': '42-48'
|
||||
'49 - 57kg': '49-57'
|
||||
'58 - 66kg': '58-66'
|
||||
'67 - 78kg': '67-78'
|
||||
'79 - 85kg': '79-85'
|
||||
'86 - 94kg': '86-94'
|
||||
'95kg oder mehr': '95k+'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -134,7 +39,6 @@ services:
|
||||
$xmlDump: '@xml_dump.storage'
|
||||
$jsonExport: '@json_export.storage'
|
||||
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
|
||||
$termsAndConditionsUrl: '%terms_and_conditions_url%'
|
||||
$logger: '@monolog.logger.core'
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
@@ -148,22 +52,18 @@ services:
|
||||
- '../src/Model'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
App\BusProNet\Transport\XmlChannel:
|
||||
App\BusProNet\ApiClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
$options:
|
||||
bpn_username: '%env(APP_BPN_USER)%'
|
||||
bpn_password: '%env(APP_BPN_PASSWORD)%'
|
||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
|
||||
App\BusProNet\Transport\SocketTransport:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
$host: '%env(APP_BPN_IP)%'
|
||||
$ports: '%env(csv:APP_BPN_PORTS)%'
|
||||
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||
|
||||
App\Twig\AppRuntime:
|
||||
arguments:
|
||||
@@ -173,278 +73,55 @@ services:
|
||||
arguments:
|
||||
$path: '%path_to_keys%'
|
||||
|
||||
App\Menu\AdminMenuBuilder:
|
||||
arguments:
|
||||
$factory: '@knp_menu.factory'
|
||||
tags:
|
||||
- name: knp_menu.menu_builder
|
||||
method: createMainMenu
|
||||
alias: admin_main
|
||||
|
||||
App\Service\BpnXmlSyncManager:
|
||||
arguments:
|
||||
$xmlSource: '@xml_source.storage'
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$bpnCache: '@bpn.cache'
|
||||
|
||||
App\Command\BpnXmlCacheInvalidateCommand:
|
||||
arguments:
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$bpnCache: '@bpn.cache'
|
||||
$cache: '@cache.app'
|
||||
|
||||
App\Security\Crypt:
|
||||
arguments:
|
||||
$path: '%path_to_keys%'
|
||||
|
||||
App\Service\TravelSnapshotManager:
|
||||
arguments:
|
||||
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
||||
|
||||
App\Service\TravelDataProvider:
|
||||
App\Service\TravelDataService:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.core'
|
||||
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
|
||||
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
|
||||
|
||||
App\Service\ParticipantFormSupport:
|
||||
arguments:
|
||||
$bodyDimensionRanges: '%body_dimension_ranges%'
|
||||
|
||||
App\Service\BookingEditDataLoader:
|
||||
arguments:
|
||||
$bpnCache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\TravelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\Utility\CountryCodeMapper:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\BusProNet\XmlLoader\HotelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\PickupLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\InsuranceLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\Service\TravelExportBuilder:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\ProductExportBuilder:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherValidator:
|
||||
App\Service\VoucherValidationService:
|
||||
arguments:
|
||||
$cache: '@cache.app'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherCodeClassifier:
|
||||
arguments:
|
||||
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
|
||||
|
||||
# Voucher purchase flow (/gutschein)
|
||||
App\Service\VoucherTemplateProvider:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherThumbnailResolver:
|
||||
arguments:
|
||||
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Controller\Voucher\Step1Controller:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Controller\Voucher\Step3Controller:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
# Field Handlers with dependencies
|
||||
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
||||
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
||||
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
|
||||
App\Form\Service\ParticipantVoucherFieldHandler: ~
|
||||
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
|
||||
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
|
||||
|
||||
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
|
||||
App\Form\Service\ParticipantFieldHandlerRegistry:
|
||||
arguments:
|
||||
$handlers:
|
||||
# Simple handlers with no external dependencies - instantiated directly
|
||||
- 'App\Form\Service\ParticipantDateOfBirthFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAssignedRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRemarksRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAdditionalServicesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantCoursesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantBoardFieldHandler'
|
||||
- 'App\Form\Service\ParticipantVegFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRentalsFieldHandler'
|
||||
- 'App\Form\Service\ParticipantSkiPassFieldHandler'
|
||||
- 'App\Form\Service\ParticipantTransportationOutboundFieldHandler'
|
||||
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
|
||||
- 'App\Form\Service\ParticipantPickupFieldHandler'
|
||||
- 'App\Form\Service\ParticipantDropOffFieldHandler'
|
||||
- 'App\Form\Service\ParticipantParkingFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
|
||||
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
|
||||
# Complex handlers with dependencies - use service references
|
||||
- '@App\Form\Service\ParticipantDateOfBirthFieldHandler'
|
||||
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
|
||||
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantParkingFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
||||
|
||||
# Booking Status Rules
|
||||
App\BusProNet\Service\StatusRule\Selection1473StatusRule: ~
|
||||
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
|
||||
|
||||
# Booking Status Rule Registry
|
||||
App\BusProNet\Service\BookingStatusRuleRegistry:
|
||||
arguments:
|
||||
$rules:
|
||||
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||
|
||||
# Dashboard Widgets
|
||||
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
|
||||
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
||||
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
||||
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
||||
|
||||
# Dashboard Widget Registry
|
||||
App\Dashboard\DashboardWidgetRegistry:
|
||||
arguments:
|
||||
$providers:
|
||||
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||
|
||||
App\ContentFeed\ContentFeedClient:
|
||||
arguments:
|
||||
$baseUrl: '%env(APP_CMS_API_BASE_URL)%'
|
||||
$apiKey: '%env(APP_CMS_API_KEY)%'
|
||||
|
||||
App\Chatbot\TravelPeriods:
|
||||
arguments:
|
||||
$periods: '%chatbot_travel_periods%'
|
||||
|
||||
App\Security\McpAccessTokenHandler:
|
||||
arguments:
|
||||
$tokens: '%env(APP_MCP_TOKENS)%'
|
||||
|
||||
App\Service\CmsDataProvider:
|
||||
App\Service\CmsDataService:
|
||||
arguments:
|
||||
$httpClient: '@typo3.client'
|
||||
|
||||
App\Logger\RequestIdProcessor:
|
||||
tags:
|
||||
- { name: monolog.processor }
|
||||
|
||||
App\Service\NewsletterManager:
|
||||
arguments:
|
||||
$newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%'
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
$defaultMailjetListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
|
||||
App\Controller\Api\NewsletterSubscriptionController:
|
||||
arguments:
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
|
||||
App\Controller\Webhook\MailjetNewsletterWebhookController:
|
||||
arguments:
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
|
||||
App\BusProNet\XmlParser\CrmAttributesResponseParser:
|
||||
arguments:
|
||||
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
||||
|
||||
App\Security\EmployeeDomainMatcher:
|
||||
arguments:
|
||||
$domains: '%employee_email_domains%'
|
||||
|
||||
App\MessageHandler\RoleNominationHandler:
|
||||
arguments:
|
||||
$notificationRecipients: '%role_nomination_notification_emails%'
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
|
||||
App\Email\Mailer:
|
||||
arguments:
|
||||
$defaults:
|
||||
from: '%default_email_from%'
|
||||
to: '%default_email_to%'
|
||||
|
||||
App\Service\AccommodationBookingService:
|
||||
arguments:
|
||||
$accommodationEmail: '%accommodation_inquiry_email%'
|
||||
|
||||
App\Service\AccommodationTermsUrlProvider:
|
||||
arguments:
|
||||
$config:
|
||||
AT: '%env(ACCOMMODATION_TERMS_AT)%'
|
||||
CH: '%env(ACCOMMODATION_TERMS_CH)%'
|
||||
IT: '%env(ACCOMMODATION_TERMS_IT)%'
|
||||
|
||||
App\Service\GroupsPriceCalculator:
|
||||
arguments:
|
||||
$config:
|
||||
runningCostsEur: '%env(float:APP_RUNNING_COSTS_FACTOR_EUR)%'
|
||||
runningCostsChf: '%env(float:APP_RUNNING_COSTS_FACTOR_CHF)%'
|
||||
undersubscription30Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_EUR)%'
|
||||
undersubscription30Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_30_CHF)%'
|
||||
undersubscription40Eur: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_EUR)%'
|
||||
undersubscription40Chf: '%env(float:APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF)%'
|
||||
|
||||
App\Validator\Constraints\ParticipantValidator:
|
||||
arguments:
|
||||
$bodyDimensionRanges: '%body_dimension_ranges%'
|
||||
|
||||
App\BpnConnect\AbstractApiClient:
|
||||
abstract: true
|
||||
arguments:
|
||||
$baseUrl: '%env(default::BPN_CONNECT_BASE_URL)%'
|
||||
$apiKey: '%env(default::BPN_CONNECT_API_KEY)%'
|
||||
|
||||
App\BpnConnect\ContingentsClient:
|
||||
parent: App\BpnConnect\AbstractApiClient
|
||||
|
||||
App\BpnConnect\TravelsClient:
|
||||
parent: App\BpnConnect\AbstractApiClient
|
||||
|
||||
App\ClickUp\ApiClient:
|
||||
arguments:
|
||||
$apiToken: '%env(default::CLICKUP_API_TOKEN)%'
|
||||
$listId: '%env(default::CLICKUP_LIST_ID)%'
|
||||
$templateId: '%env(default::CLICKUP_TEMPLATE_ID)%'
|
||||
$baseUrl: '%env(default::CLICKUP_API_BASE_URL)%'
|
||||
|
||||
App\Mailjet\ApiClient:
|
||||
arguments:
|
||||
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
||||
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
|
||||
App\EventListener\MaintenanceModeListener:
|
||||
arguments:
|
||||
$options:
|
||||
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
|
||||
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
|
||||
@@ -1,19 +0,0 @@
|
||||
parameters:
|
||||
# domain mapping for theme, gtm id and cmp url
|
||||
domain_config:
|
||||
myep.ddev.site:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
mysbw.ddev.site:
|
||||
theme: sbw
|
||||
gtm_id: GTM-MS2C8CH
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
|
||||
myser.ddev.site:
|
||||
theme: ser
|
||||
gtm_id: GTM-MNKDKL9
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
|
||||
mysnz.ddev.site:
|
||||
theme: snz
|
||||
gtm_id: GTM-M96CKL
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
|
||||
@@ -2,7 +2,7 @@ const {defineConfig} = require("cypress");
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'https://myep.ddev.site',
|
||||
baseUrl: 'https://myep-next-booking.ddev.site',
|
||||
supportFile: false,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,7 @@ require 'recipe/symfony.php';
|
||||
add('shared_dirs', [
|
||||
'config/secret',
|
||||
'var/bpn',
|
||||
'var/log',
|
||||
'var/sessions',
|
||||
'var/jsonexport',
|
||||
'var/xmlexport',
|
||||
@@ -16,6 +17,7 @@ add('shared_dirs', [
|
||||
|
||||
add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'.env.local.php',
|
||||
]);
|
||||
|
||||
set('keep_releases', 3);
|
||||
@@ -31,16 +33,12 @@ $rsyncOptions = [
|
||||
'.ddev',
|
||||
'.git',
|
||||
'.github',
|
||||
'.jj',
|
||||
'node_modules',
|
||||
'.editorconfig',
|
||||
'.env.local',
|
||||
'.env.local.php',
|
||||
'.env.dev.local',
|
||||
'.env.dev.local.php',
|
||||
'.env.test',
|
||||
'.env.test.local',
|
||||
'.browserslistrc',
|
||||
'.gitignore',
|
||||
'.nvmrc',
|
||||
'.php-cs-fixer.cache',
|
||||
@@ -48,18 +46,16 @@ $rsyncOptions = [
|
||||
'.phpunit.result.cache',
|
||||
'config/secret/',
|
||||
'CLAUDE.md',
|
||||
'cypress*',
|
||||
'deploy.php',
|
||||
'docs',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'*.http',
|
||||
'http-client*.json',
|
||||
'http-client.private.env.json',
|
||||
'postcss.config.js',
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'ray.php',
|
||||
'webpack.config.mjs',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'bin/.phpunit',
|
||||
'temp/',
|
||||
'tests/',
|
||||
@@ -77,70 +73,53 @@ $rsyncOptions = [
|
||||
'timeout' => 300,
|
||||
];
|
||||
|
||||
// Shared by all hosts.
|
||||
set('bin/php', '/usr/bin/php');
|
||||
set('http_user', 'myepsf');
|
||||
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||
set('rsync_src', __DIR__);
|
||||
set('rsync', $rsyncOptions);
|
||||
|
||||
host('prod')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setDeployPath('/usr/www/users/myepsf/prod')
|
||||
->setRemoteUser('myepsf')
|
||||
->setHostname('92.205.212.39')
|
||||
->setRemoteUser('ssh-53857-root')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
->setDeployPath('/kunden/tiefschnee.de/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'ssh-53857-root')
|
||||
->set('bin/php', '/usr/bin/php82')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||
->setRemoteUser('myepsf')
|
||||
->setHostname('185.243.132.170')
|
||||
->setRemoteUser('p546473')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'public/.htpasswd',
|
||||
])
|
||||
->setDeployPath('/home/www/p546473/html/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('keep_releases', 5)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep.dreipunktnull.net')
|
||||
;
|
||||
|
||||
task('deploy', [
|
||||
'deploy:info',
|
||||
'deploy:assets',
|
||||
'deploy:setup',
|
||||
'deploy:lock',
|
||||
'deploy:release',
|
||||
'deploy:assets',
|
||||
'rsync',
|
||||
'deploy:shared',
|
||||
'deploy:writable',
|
||||
'deploy:cache:warmup',
|
||||
'database:migrate',
|
||||
// Must run before the symlink flip: the cachetool probe file is only reachable through
|
||||
// the release the docroot currently resolves to. It still pays off, because it drops the
|
||||
// opcache entries keyed under current/public/*.
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:publish',
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:stop-workers',
|
||||
]);
|
||||
|
||||
// Purely local, so it runs before anything is created on the remote. once(), so
|
||||
// deploying multiple hosts at the same time builds the bundle only once.
|
||||
task('deploy:assets', function () {
|
||||
runLocally('ddev exec npm ci');
|
||||
runLocally('ddev exec npm run build');
|
||||
})->once();
|
||||
|
||||
// The recipe's deploy:cache:clear only does something when composer ran with
|
||||
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
|
||||
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
|
||||
// Runs after deploy:writable so the default ACLs are already in place.
|
||||
task('deploy:cache:warmup', function () {
|
||||
run('{{bin/console}} cache:warmup {{console_options}}');
|
||||
runLocally('npm i && npm run build');
|
||||
});
|
||||
|
||||
// Workers keep running the previous release's code until they are told to stop.
|
||||
task('deploy:stop-workers', function () {
|
||||
run('{{bin/console}} messenger:stop-workers');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
# Symfony 7.4 Upgrade Plan
|
||||
|
||||
**Project:** MyEP Next Booking
|
||||
**Current Version:** Symfony 6.4.31
|
||||
**Target Version:** Symfony 7.4 (LTS)
|
||||
**Date:** 2026-01-05
|
||||
**Status:** Ready for Upgrade
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Estimated Effort: LOW**
|
||||
**Risk Level: LOW**
|
||||
**Status: READY FOR UPGRADE**
|
||||
|
||||
The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade. The codebase follows modern Symfony best practices, uses PHP 8 features extensively, and avoids most deprecated patterns. All pre-upgrade fixes have been completed.
|
||||
|
||||
**Key Facts:**
|
||||
- Symfony 7.4 was released on November 27, 2025
|
||||
- Requires PHP 8.2.0+ (current: PHP 8.2.29)
|
||||
- LTS version with bug fixes until November 2028, security fixes until November 2029
|
||||
- Symfony 8.0 = Symfony 7.4 minus deprecations (same features, PHP 8.4 required)
|
||||
|
||||
**Pre-Upgrade Status:**
|
||||
- PHP version: 8.2.29 (compatible)
|
||||
- OAuth2 Server Bundle: 1.1.0 (compatible)
|
||||
- Stimulus Bundle: 2.32.0 (compatible)
|
||||
- KnpMenuBundle: Removed (no longer in use)
|
||||
- Doctrine ORM: 3.6.0 (compatible)
|
||||
- All 312 tests passing
|
||||
- Email validation mode: Configured (html5)
|
||||
- 18 test-only deprecation warnings (cosmetic, do not affect runtime)
|
||||
|
||||
---
|
||||
|
||||
## Previous Plan Status
|
||||
|
||||
The previous upgrade plan (dated 2025-12-06) has been updated. All pre-upgrade tasks are now complete.
|
||||
|
||||
### Completed Items
|
||||
| Item | Previous Status | Current Status |
|
||||
|------|----------------|----------------|
|
||||
| OAuth2 Server Bundle | 1.0.0 | **1.1.0 installed** |
|
||||
| Stimulus Bundle | 2.31.0 | **2.32.0 installed** |
|
||||
| PHP 8.2 deprecations (ParticipantDto::$touched) | Planned | **Completed** |
|
||||
| Doctrine config (controller_resolver) | Planned | **Completed** |
|
||||
| SecurityController null safety | Needs verification | **Completed** (uses `?->` and `??`) |
|
||||
| Email validator "loose" mode deprecation | Needs fix | **Completed** (validator.yaml configured) |
|
||||
| Failing test in BookingDataProcessorTest | Unrelated to upgrade | **Resolved** (test now passes) |
|
||||
|
||||
### Items No Longer Relevant
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| KnpMenuBundle upgrade | **Bundle removed from project** |
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### PHP Version Compatibility
|
||||
|
||||
- **Current Runtime:** PHP 8.2.29
|
||||
- **Composer Platform:** PHP 8.2.28
|
||||
- **Symfony 7.4 Required:** PHP 8.2.0+
|
||||
- **Status:** Compatible
|
||||
|
||||
### Installed Dependencies
|
||||
|
||||
#### Core Symfony Packages (6.4.x)
|
||||
All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.4.*
|
||||
|
||||
#### Third-Party Bundles - Compatible
|
||||
| Package | Installed | Symfony 7.4 Compatible |
|
||||
|---------|-----------|------------------------|
|
||||
| `doctrine/orm` | 3.6.0 | Yes |
|
||||
| `doctrine/doctrine-bundle` | 2.18.1 | Yes |
|
||||
| `doctrine/doctrine-migrations-bundle` | 3.7.0 | Yes |
|
||||
| `league/oauth2-server-bundle` | 1.1.0 | Yes |
|
||||
| `symfony/stimulus-bundle` | 2.32.0 | Yes |
|
||||
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
|
||||
| `symfony/monolog-bundle` | 3.11.0 | Yes |
|
||||
| `zenstruck/schedule-bundle` | 1.9.0 | Yes |
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
The codebase implements modern Symfony/PHP patterns:
|
||||
- All controllers use PHP 8 attributes (`#[Route]`)
|
||||
- Constructor property promotion used throughout
|
||||
- Strict types enforced on all files
|
||||
- Typed properties and return type declarations
|
||||
- Entities use PHP 8 attributes for ORM mapping
|
||||
- Modern security system (AbstractLoginFormAuthenticator)
|
||||
- No deprecated annotation usage
|
||||
|
||||
---
|
||||
|
||||
## Pre-Upgrade Fixes - COMPLETED
|
||||
|
||||
### 1. Email Validator "loose" Mode Deprecation - DONE
|
||||
|
||||
**Issue:** The default mode for `#[Assert\Email]` is "loose", which is deprecated in Symfony 6.2+ and removed in Symfony 7.0.
|
||||
|
||||
**Resolution:** Framework default configured in `config/packages/validator.yaml`:
|
||||
|
||||
```yaml
|
||||
framework:
|
||||
validation:
|
||||
email_validation_mode: html5
|
||||
```
|
||||
|
||||
All Email constraints in the codebase also explicitly use `mode: 'strict'`:
|
||||
- `src/Form/Model/ParticipantDto.php` - Has `mode: 'strict'`
|
||||
- `src/Form/Model/RegistrationDto.php` - Has `mode: 'strict'`
|
||||
- `src/BusProNet/Model/Communication.php` - Has `mode: 'strict'`
|
||||
- `src/Controller/ResetPasswordController.php` - Has `mode: 'strict'`
|
||||
|
||||
### 2. Test Deprecation Warnings - COSMETIC ONLY
|
||||
|
||||
**Issue:** 18 deprecation warnings appear during test runs.
|
||||
|
||||
**Cause:** `ParticipantEditDtoTest` creates a standalone validator using `Validation::createValidatorBuilder()` which bypasses the framework's `validator.yaml` configuration.
|
||||
|
||||
**Impact:** These warnings are cosmetic and do not affect:
|
||||
- Runtime behavior (all Email constraints use explicit `mode: 'strict'`)
|
||||
- Symfony 7.4 compatibility (no code changes needed)
|
||||
- Test results (all 312 tests pass)
|
||||
|
||||
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
|
||||
|
||||
---
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. Update Composer Version Constraints
|
||||
|
||||
**File:** `composer.json`
|
||||
|
||||
#### Symfony Core Packages
|
||||
Update all packages from `6.4.*` to `7.4.*`:
|
||||
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dom-crawler": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/html-sanitizer": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/lock": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Development Packages
|
||||
|
||||
```json
|
||||
{
|
||||
"require-dev": {
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Symfony Flex Configuration
|
||||
|
||||
**File:** `composer.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": true,
|
||||
"require": "7.4.*",
|
||||
"docker": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Potential Breaking Changes
|
||||
|
||||
### 1. Symfony Core Changes (Symfony 7.0 -> 7.4)
|
||||
|
||||
Symfony 7.0 removed all deprecated features from 6.4. Our codebase is fully compliant:
|
||||
|
||||
- **Native Return Types:** Already implemented
|
||||
- **Attribute-based Configuration:** Already implemented
|
||||
- **Security System:** Modern authenticator pattern in use
|
||||
- **Form System:** Modern approach in use
|
||||
- **No Annotation Usage:** All using PHP 8 attributes
|
||||
|
||||
### 2. Email Validation Mode
|
||||
|
||||
**Breaking Change:** The default `mode` for `Email` constraint changes from `loose` to `html5` in Symfony 7.0.
|
||||
|
||||
**Status:** Already handled - all constraints use explicit `mode: 'strict'` and framework default is set to `html5`.
|
||||
|
||||
### 3. Deprecation Cleanup
|
||||
|
||||
Deprecations accumulated in Symfony 7.1-7.4 will need review:
|
||||
- Check Symfony Profiler after upgrade for any new deprecation warnings
|
||||
- Review [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
|
||||
- Review [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Procedure
|
||||
|
||||
### Phase 1: Preparation (15 minutes)
|
||||
|
||||
1. **Create Feature Branch**
|
||||
```bash
|
||||
git checkout -b feature/symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
2. **Backup Current State**
|
||||
```bash
|
||||
git tag before-symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
### Phase 2: Dependency Updates (30 minutes - 1 hour)
|
||||
|
||||
1. **Update composer.json**
|
||||
- Update all Symfony package constraints to `7.4.*`
|
||||
- Update `extra.symfony.require` to `"7.4.*"`
|
||||
|
||||
2. **Run Composer Update**
|
||||
```bash
|
||||
ddev composer update
|
||||
```
|
||||
|
||||
3. **Review Recipe Updates**
|
||||
```bash
|
||||
ddev composer recipes
|
||||
```
|
||||
Review any recipe updates from Symfony Flex and apply as needed.
|
||||
|
||||
4. **Clear All Caches**
|
||||
```bash
|
||||
ddev exec bin/console cache:clear
|
||||
ddev exec bin/console cache:warmup
|
||||
ddev exec "php -r 'opcache_reset();'"
|
||||
```
|
||||
|
||||
### Phase 3: Testing & Validation (1-2 hours)
|
||||
|
||||
1. **Run PHPUnit Test Suite**
|
||||
```bash
|
||||
ddev exec ./vendor/bin/phpunit
|
||||
```
|
||||
Check for:
|
||||
- Test failures
|
||||
- Deprecation warnings
|
||||
- Fatal errors
|
||||
|
||||
2. **Run PHP-CS-Fixer**
|
||||
```bash
|
||||
ddev php vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
```
|
||||
|
||||
3. **Manual Testing - Critical Paths**
|
||||
- [ ] User authentication (BPN API login)
|
||||
- [ ] OAuth2 token generation (`/token` endpoint)
|
||||
- [ ] OAuth2 authorization flow (`/authorize` endpoint)
|
||||
- [ ] API endpoints (all `/api/*` routes)
|
||||
- [ ] Booking creation flow (Steps 1-4)
|
||||
- [ ] Participant form rendering and submission
|
||||
- [ ] Dynamic field updates (HTMX)
|
||||
- [ ] Real-time pricing calculations
|
||||
- [ ] Insurance selection and filtering
|
||||
- [ ] Transportation service selection
|
||||
- [ ] Payment method selection
|
||||
- [ ] Final booking submission to BPN API
|
||||
- [ ] Booking edit functionality
|
||||
- [ ] PDF/XML download functionality
|
||||
- [ ] User registration
|
||||
- [ ] Password reset
|
||||
- [ ] Personal data management
|
||||
|
||||
4. **Check Symfony Profiler**
|
||||
- Review deprecation warnings (should be minimal)
|
||||
- Check performance metrics
|
||||
- Verify no errors in logs
|
||||
|
||||
5. **Test Frontend Assets**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
- Verify Stimulus controllers load correctly
|
||||
- Test HTMX interactions
|
||||
- Verify Toastify notifications
|
||||
- Check TailwindCSS compilation
|
||||
|
||||
### Phase 4: Bug Fixes (0-2 hours)
|
||||
|
||||
Address any issues discovered during testing:
|
||||
|
||||
1. **Review Error Logs**
|
||||
```bash
|
||||
ddev logs
|
||||
```
|
||||
|
||||
2. **Fix Any Remaining Deprecations**
|
||||
- Use Symfony Profiler to identify deprecations
|
||||
- Update code to use modern alternatives
|
||||
|
||||
### Phase 5: Deployment (1 hour)
|
||||
|
||||
1. **Deploy to Staging**
|
||||
```bash
|
||||
ddev exec vendor/bin/dep deploy staging
|
||||
```
|
||||
|
||||
2. **Staging Validation**
|
||||
- Full regression testing
|
||||
- Performance testing
|
||||
|
||||
3. **Production Deployment**
|
||||
- Schedule maintenance window
|
||||
- Deploy during low-traffic period
|
||||
- Monitor error logs closely
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### Immediate Rollback
|
||||
|
||||
If critical issues are discovered:
|
||||
|
||||
1. **Git Revert**
|
||||
```bash
|
||||
git checkout master
|
||||
git reset --hard before-symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
2. **Restore Dependencies**
|
||||
```bash
|
||||
ddev composer install
|
||||
```
|
||||
|
||||
3. **Clear Caches**
|
||||
```bash
|
||||
ddev exec bin/console cache:clear
|
||||
```
|
||||
|
||||
### Production Rollback
|
||||
|
||||
If issues occur in production:
|
||||
|
||||
```bash
|
||||
ddev exec vendor/bin/dep rollback production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] No new deprecation warnings
|
||||
- [ ] Code coverage maintained
|
||||
|
||||
### Integration Tests
|
||||
- [ ] BusProNet API integration
|
||||
- [ ] XML parsing and data processing
|
||||
- [ ] Booking price calculations
|
||||
- [ ] Insurance assignment logic
|
||||
- [ ] Field handler system
|
||||
|
||||
### Functional Tests
|
||||
- [ ] User authentication flow
|
||||
- [ ] OAuth2 token generation
|
||||
- [ ] API endpoints respond correctly
|
||||
- [ ] Booking creation (all steps)
|
||||
- [ ] Form validation
|
||||
- [ ] HTMX partial updates
|
||||
- [ ] Dynamic field state changes
|
||||
- [ ] Real-time pricing updates
|
||||
- [ ] Toast notifications
|
||||
|
||||
### Browser Testing
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Mobile browsers
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Estimated Time | Notes |
|
||||
|-------|---------------|-------|
|
||||
| **Phase 1:** Preparation | 15 min | Branch creation |
|
||||
| **Phase 2:** Dependency Updates | 30 min - 1 hour | composer.json updates, composer update |
|
||||
| **Phase 3:** Testing & Validation | 1-2 hours | Automated + manual testing |
|
||||
| **Phase 4:** Bug Fixes | 0-2 hours | Address any issues |
|
||||
| **Phase 5:** Deployment | 1 hour | Staging deployment |
|
||||
| **Total** | **2.5-6 hours** | Average: **3.5 hours** |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations: Symfony 8.0
|
||||
|
||||
Symfony 8.0 was released alongside 7.4 (November 2025) with identical features but:
|
||||
- Requires PHP 8.4
|
||||
- No deprecation layers (all deprecated code removed)
|
||||
- Shorter support window (8 months)
|
||||
|
||||
**Recommendation:** Stay on Symfony 7.4 LTS for stability. Plan PHP 8.4 upgrade separately, then consider Symfony 8.x path.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] No deprecation warnings in Symfony Profiler
|
||||
- [ ] All critical user flows functional
|
||||
- [ ] OAuth2 authentication working
|
||||
- [ ] BPN API integration operational
|
||||
- [ ] Booking creation flow complete
|
||||
- [ ] Frontend assets compile and load
|
||||
- [ ] Performance metrics comparable or better
|
||||
- [ ] No errors in production logs after 24 hours
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Official Symfony Documentation
|
||||
- [Upgrade Major Version Guide](https://symfony.com/doc/current/setup/upgrade_major.html)
|
||||
- [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
|
||||
- [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
|
||||
- [Symfony 7.4 Release Notes](https://symfony.com/releases/7.4)
|
||||
- [Preparing for Symfony 7.4 and 8.0](https://symfony.com/blog/preparing-for-symfony-7-4-and-symfony-8-0)
|
||||
|
||||
### Bundle-Specific Documentation
|
||||
- [OAuth2 Server Bundle](https://github.com/thephpleague/oauth2-server-bundle)
|
||||
- [Doctrine ORM 3.x Documentation](https://www.doctrine-project.org/projects/orm.html)
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 5.0
|
||||
**Previous Version:** 4.0 (dated 2025-12-06)
|
||||
**Last Updated:** 2026-01-05
|
||||
**Status:** Ready for Symfony 7.4 Upgrade
|
||||
@@ -1,728 +0,0 @@
|
||||
# API Consumer Guide
|
||||
|
||||
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||||
|
||||
## Overview
|
||||
|
||||
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Token endpoint: `POST /token` (unauthenticated).
|
||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||
- Access token TTL is **10 minutes** for every grant.
|
||||
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
|
||||
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
|
||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
|
||||
Scope required per endpoint:
|
||||
|
||||
| Scope needed | Endpoints |
|
||||
|---|---|
|
||||
| `api` | everything except the two below |
|
||||
| `profile` | `GET /api/crm-attributes` |
|
||||
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||||
|
||||
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||||
|
||||
### Machine-to-machine example
|
||||
|
||||
```
|
||||
POST /token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/hotels
|
||||
Authorization: Bearer eyJ0eXAi...
|
||||
```
|
||||
|
||||
## Error conventions
|
||||
|
||||
There is no single error envelope; three shapes occur:
|
||||
|
||||
- `{"message": "..."}` — commonly with 404 / 400.
|
||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||||
|
||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Reference data (BusProNet master data)
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||||
`costUnit`, `countryCode`.
|
||||
|
||||
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||||
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||||
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||||
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||||
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||||
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||||
the mapping table is extended.
|
||||
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||||
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||||
`404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
Array of `{id, name, token, nationality}`.
|
||||
|
||||
#### `GET /api/products` — scope `api`
|
||||
|
||||
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||||
|
||||
#### `GET /api/last-update` — scope `api`
|
||||
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
|
||||
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
|
||||
status 200 — treat null as "unknown", not as "changed".
|
||||
|
||||
---
|
||||
|
||||
### Pickups (bus boarding points)
|
||||
|
||||
#### `GET /api/pickups` — scope `api`
|
||||
|
||||
Array of `{id, code, city, postalCode, street}`.
|
||||
|
||||
#### `GET /api/pickups/{id}` — scope `api`
|
||||
|
||||
Single pickup by numeric id, same fields as the list.
|
||||
|
||||
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||||
|
||||
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||||
|
||||
```json
|
||||
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||||
```
|
||||
|
||||
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||||
|
||||
#### `POST /api/pickups-planning` — scope `api`
|
||||
|
||||
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PlanungsArt": "HIN",
|
||||
"Reisecode": "ABC123",
|
||||
"Zustieg/Ausstieg Ort": "Köln",
|
||||
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||||
"Zeit": "24.12.2026 07:30",
|
||||
"BusNummer": "1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||||
|
||||
This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Bulk exports
|
||||
|
||||
#### `GET /api/exports/travels` — scope `api`
|
||||
|
||||
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||||
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||||
|
||||
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
|
||||
|
||||
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||||
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||||
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||||
| `perPage` | int 1–1000 | `500` | |
|
||||
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||||
|
||||
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||||
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||||
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"busProId": 12794,
|
||||
"code": "PCJSER261226",
|
||||
"productCode": "PCJSER",
|
||||
"product": "Semesterende - Portes du Soleil - Joliment",
|
||||
"dateFrom": "2026-12-26",
|
||||
"dateTo": "2026-12-30",
|
||||
"hotel": {
|
||||
"busProId": 225444,
|
||||
"code": "PCJSER1",
|
||||
"name": "NEU SER Jolimont 1",
|
||||
"costUnit": "863",
|
||||
"country": "CH",
|
||||
"countryCode": "ch"
|
||||
},
|
||||
"pickups": [
|
||||
{
|
||||
"busProId": 18,
|
||||
"code": "ESS-Route",
|
||||
"city": "Essen",
|
||||
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||
"time": "20:15",
|
||||
"datetime": "2026-12-25T20:15:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"perPage": 500,
|
||||
"total": 415,
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
|
||||
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||||
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||
empty, never null.
|
||||
|
||||
**`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
|
||||
recognise dates of the same product. Do not derive it from the date `code` by cutting off the date: some
|
||||
products run dates under a different code (`DWWMP` runs `DWWMW…`), and group travels carry each group's own
|
||||
code on the date. It is `null` for the few travels that have no code upstream.
|
||||
|
||||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
|
||||
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||
|
||||
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||||
lost rows you previously had.
|
||||
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||||
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||||
with no name is more useful to a disposition view than one that silently vanished.
|
||||
|
||||
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||||
|
||||
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
|
||||
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
|
||||
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
|
||||
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
|
||||
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
|
||||
an offset to compare or sort them; they already sort correctly as strings.
|
||||
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
|
||||
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
|
||||
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
|
||||
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
|
||||
`datetime` carries that resolution for bare-time sources too.
|
||||
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||
null together.
|
||||
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||||
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||||
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||||
|
||||
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||||
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||
|
||||
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
|
||||
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
|
||||
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
|
||||
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
|
||||
|
||||
#### `GET /api/exports/products` — scope `api`
|
||||
|
||||
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
|
||||
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
|
||||
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
|
||||
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
|
||||
|
||||
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
|
||||
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
|
||||
them a website shows, and how, is the client's rule, not this endpoint's.
|
||||
|
||||
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
|
||||
returns it in one page.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"product": { "code": "DKIWE", "busProId": 1127 },
|
||||
"date": {
|
||||
"code": "DKIWE041226",
|
||||
"busProId": 12437,
|
||||
"from": "2026-12-04",
|
||||
"to": "2026-12-06",
|
||||
"type": "F"
|
||||
},
|
||||
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
|
||||
"pseudoPrice": null,
|
||||
"selectionGroups": [
|
||||
{
|
||||
"busProId": 7,
|
||||
"label": "Leistungen Skipass",
|
||||
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
|
||||
}
|
||||
],
|
||||
"transportServices": [
|
||||
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
|
||||
],
|
||||
"dateServices": [
|
||||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||
],
|
||||
"hotelServices": [
|
||||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"busProId": 74,
|
||||
"code": "MBZET",
|
||||
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
|
||||
"category": "GR",
|
||||
"minPax": 1,
|
||||
"maxPax": 1,
|
||||
"nights": 2,
|
||||
"price": 229,
|
||||
"available": 24,
|
||||
"status": "Frei",
|
||||
"boardCode": "Lt. Auss.",
|
||||
"boardBusProId": 2
|
||||
}
|
||||
],
|
||||
"pickups": [
|
||||
{
|
||||
"busProId": 4,
|
||||
"code": "FRA-Route",
|
||||
"city": "Frankfurt",
|
||||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||
"price": 5.9,
|
||||
"returnLeg": true,
|
||||
"returnPrice": 5.9,
|
||||
"datetime": "2026-12-03T23:30:00"
|
||||
}
|
||||
],
|
||||
"guide": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"perPage": 500,
|
||||
"total": 419,
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
|
||||
"generatedAt": "2026-09-30T14:05:00+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
|
||||
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
|
||||
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
|
||||
|
||||
**Types.**
|
||||
|
||||
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
|
||||
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
|
||||
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
|
||||
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
|
||||
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
|
||||
|
||||
**Rooms.** One entry per room offered on the date at that hotel.
|
||||
|
||||
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
|
||||
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
|
||||
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
|
||||
empty.
|
||||
|
||||
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
|
||||
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
|
||||
|
||||
**Services.** Three lists, deliberately kept apart:
|
||||
|
||||
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
|
||||
- `dateServices` — the date's general services.
|
||||
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
|
||||
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
|
||||
`busProId`: it identifies a service across both lists.
|
||||
|
||||
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
|
||||
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
|
||||
such as for arriving by car.
|
||||
|
||||
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
|
||||
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
|
||||
|
||||
| `busProId` | Label |
|
||||
|---|---|
|
||||
| 7 | Leistungen Skipass |
|
||||
| 8 | Leistungen Sonstige |
|
||||
| 71 | Leistungen Verpflegung |
|
||||
| 85 | Leistungen Übernachtung |
|
||||
| 86 | Leistungen Programm |
|
||||
| 87 | Leistungen Kurse |
|
||||
| 89 | Leistungen Events |
|
||||
| 90 | Leistungen Transport |
|
||||
|
||||
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
|
||||
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
||||
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
|
||||
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
|
||||
none. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and
|
||||
`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled
|
||||
against the other. A point served on the return leg only is not emitted. An id absent from
|
||||
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
|
||||
of `0` is emitted as-is but not counted.
|
||||
|
||||
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
|
||||
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||||
|
||||
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||||
|
||||
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||||
|
||||
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||||
|
||||
Both variants accept optional query parameters:
|
||||
|
||||
- `source=local` — force the local XML/snapshot data.
|
||||
- `source=remote` — force a live upstream call.
|
||||
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||||
|
||||
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||||
|
||||
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||||
|
||||
---
|
||||
|
||||
### Contingents & accommodation prices
|
||||
|
||||
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||||
|
||||
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||||
|
||||
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||||
- `year`: required int, 1000–9999.
|
||||
|
||||
Each row:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-14",
|
||||
"season": "high|low|...|null",
|
||||
"includedPax": 2,
|
||||
"pricePerNight": 89.5,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"type": "discount|null"
|
||||
}
|
||||
```
|
||||
|
||||
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||||
|
||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
Per-day availability enriched with prices.
|
||||
|
||||
`dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
||||
|
||||
**Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries.
|
||||
|
||||
Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one.
|
||||
|
||||
The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended.
|
||||
|
||||
Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale.
|
||||
|
||||
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape.
|
||||
|
||||
Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent:
|
||||
|
||||
- **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges)
|
||||
- **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet.
|
||||
|
||||
Each entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"status": "Free|Blocked|...",
|
||||
"type": "discount|null",
|
||||
"pricePerNight": 89.5,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"includedPax": 2,
|
||||
"minNights": 2
|
||||
}
|
||||
```
|
||||
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes.
|
||||
|
||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters.
|
||||
|
||||
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
|
||||
|
||||
---
|
||||
|
||||
### Operating the snapshot (deployment)
|
||||
|
||||
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||||
|
||||
Rollout order:
|
||||
|
||||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`.
|
||||
2. Deploy; `database:migrate` creates the empty tables.
|
||||
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||||
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
|
||||
|
||||
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
|
||||
|
||||
### Freshness and propagation
|
||||
|
||||
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
|
||||
|
||||
That leaves two levers, and consumers should know both:
|
||||
|
||||
- **Their own cache TTL** bounds how stale a rendered calendar can be. A one-hour TTL is the current agreed budget.
|
||||
- **Direct cache invalidation** is the only fast path when something must propagate sooner — a hotel closing, or bad availability that got cached. Consumers should keep this targeted rather than flushing everything; the TYPO3 site, for example, tags its cached calendar and price entries per hotel, so a single hotel can be flushed and re-warmed on its own.
|
||||
|
||||
On this side, staleness is bounded by the sync cadence and hard-capped by the 6-hour guard: past that, the endpoint returns `502` rather than presenting stale availability as current.
|
||||
|
||||
---
|
||||
|
||||
### Accommodation bookings (group bookings)
|
||||
|
||||
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||||
|
||||
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"status": "...",
|
||||
"type": "...",
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-08",
|
||||
"nights": 7,
|
||||
"paxCount": 40,
|
||||
"minorsCount": 12,
|
||||
"childrenCount": 3,
|
||||
"groupName": "...",
|
||||
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||||
"personalData": {},
|
||||
"accommodation": {},
|
||||
"boardService": {},
|
||||
"additionalServices": [],
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalDiscountAmount": null,
|
||||
"totalDiscountLabel": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 2,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
|
||||
|
||||
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
|
||||
|
||||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||
|
||||
`priceBreakdown` carries the resulting rows, all in minor units:
|
||||
|
||||
| field | meaning |
|
||||
| --- | --- |
|
||||
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
|
||||
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
|
||||
| `periodsTotal` | the sum of all `periods[].total` |
|
||||
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
|
||||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||||
|
||||
---
|
||||
|
||||
### Contact form
|
||||
|
||||
#### `POST /api/contactform` — scope `api`
|
||||
|
||||
Creates or matches an address record in the upstream CRM.
|
||||
|
||||
Request body (all fields optional strings; the upstream system decides what is required):
|
||||
|
||||
```json
|
||||
{
|
||||
"lastName": "...", "firstName": "...", "gender": "...",
|
||||
"street": "...", "zipCode": "...", "city": "...",
|
||||
"email": "...", "phone": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||||
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||||
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||||
- `422` — malformed payload
|
||||
|
||||
---
|
||||
|
||||
### Newsletter
|
||||
|
||||
#### `GET /api/newsletters` — scope `api`
|
||||
|
||||
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||||
|
||||
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"listIds": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
- `email`: required, strict email validation.
|
||||
- `firstName` / `lastName`: optional, max 255 chars.
|
||||
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` — subscription completed, `state: "subscribed"`.
|
||||
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||||
- Body in both cases:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"email": "[email protected]",
|
||||
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||||
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||||
"confirmationRequested": true
|
||||
}
|
||||
```
|
||||
|
||||
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||||
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||||
|
||||
---
|
||||
|
||||
### End-user endpoints (require a user-bound token)
|
||||
|
||||
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||||
|
||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||
|
||||
- always: `sub` (UUID string, see below), `email`
|
||||
- scope `id`: `person_id`, `address_id`
|
||||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
|
||||
- scope `profile`: `profile` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||||
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||||
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||||
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||||
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||||
}
|
||||
```
|
||||
|
||||
##### Identifying the account: match on `sub`
|
||||
|
||||
A BusPro address record holds several email addresses (private and business, say), and BusPro
|
||||
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
|
||||
that is intended, not a duplicate: administrative roles are reserved for the company address, so
|
||||
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
|
||||
`@ep-reisen.de` address is an administrator.
|
||||
|
||||
All of those accounts report the **same** `person_id` and `address_id`, and the same
|
||||
`profile.communication.email` — that value is the first contact address on the BusPro record and
|
||||
is **not necessarily** the one signed in with. So:
|
||||
|
||||
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
|
||||
unique across accounts. The only claim that identifies an account. Match your local user on it
|
||||
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
|
||||
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
|
||||
- **`email`** — the address this session actually authenticated with. Distinct per account, but
|
||||
treat it as a display and contact value; it is not the account key.
|
||||
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
|
||||
person's accounts, so they identify a *human*, never an account. Do not match on them.
|
||||
|
||||
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
|
||||
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
|
||||
`profile.communication.email` will disagree with MyE&P in both directions.
|
||||
|
||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||
|
||||
#### `GET /api/crm-attributes` — scope `profile`
|
||||
|
||||
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||||
|
||||
---
|
||||
|
||||
## Client implementation notes
|
||||
|
||||
1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
|
||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||
5. **Server-side caching** (1 h on products) means upstream changes may not be visible immediately. The contingent calendar is snapshot-backed instead: it is at most one sync interval behind upstream. There is no change-notification endpoint — clients are expected to cache on their own TTL and, when something must propagate sooner, invalidate their cache directly.
|
||||
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|
||||
@@ -1,902 +0,0 @@
|
||||
# bpn-connect Query Reference
|
||||
|
||||
> **Scope: endpoint design and the XML→SQL migration plan for the `bpn-connect` Go proxy.**
|
||||
> For schema semantics, conventions and confidence marks see
|
||||
> [`docs/buspro-database-reference.md`](buspro-database-reference.md), which is authoritative
|
||||
> and wins on any conflict. The table descriptions below are kept for endpoint context.
|
||||
|
||||
This document summarizes the BusProNet schema exports in
|
||||
`docs/buspronet-schema` for future `bpn-connect` endpoint/query development.
|
||||
|
||||
Generated from:
|
||||
|
||||
- `docs/buspronet-schema/BusPronet_20260601-1517.csv`: table columns
|
||||
- `docs/buspronet-schema/BusPronet_20260601-1518.csv`: foreign keys
|
||||
|
||||
Current limitation: the visible exports do not include primary-key or index
|
||||
catalog data, even though those were expected. Identity flags are not a reliable
|
||||
primary-key substitute in this database because many `ID...` columns are not
|
||||
marked as identity. Add PK/index exports before finalizing performance-sensitive
|
||||
queries.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Primary identifiers usually follow `ID<TableName>`, for example
|
||||
`Buchung.IDBuchung`, `Adresse.IDAdresse`, `AdressePerson.IDAdressePerson`.
|
||||
- Foreign key columns usually include `_FS`, for example
|
||||
`BuchungTN.IDBuchung_FS`.
|
||||
- Address data is split into:
|
||||
- `Adresse`: address/customer account root.
|
||||
- `AdressePerson`: person attached to an address.
|
||||
- `AdresseAnschrift`: postal address.
|
||||
- `AdresseKommunikation`: communication values such as email/phone.
|
||||
- Booking data is split into:
|
||||
- `Buchung`: booking header.
|
||||
- `BuchungTN`: booking participants.
|
||||
- `BuchungTNFerienziel`, `BuchungTNLeistung`: participant travel/service rows.
|
||||
- Public booking number appears to be `Buchung.Vorgang`.
|
||||
- Internal booking id is `Buchung.IDBuchung`.
|
||||
- Many tables have `Status` fields. Based on observed data, `Status = 'L'`
|
||||
appears to be a soft-delete marker and should be excluded from portal lookup
|
||||
queries. Other status values still need table-specific business validation.
|
||||
|
||||
## Core Tables
|
||||
|
||||
### `Buchung`
|
||||
|
||||
Booking header. Important columns:
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDBuchung` | `bigint` | no | Internal booking id. |
|
||||
| `Vorgang` | `nvarchar(100)` | no | Public booking number/reference. |
|
||||
| `IDReise_FS` | `bigint` | yes | Links to `Reise.IDReise`; no FK was present in the export. |
|
||||
| `IDAdresse_FS_Anmelder` | `bigint` | yes | Applicant address id; no FK was present in the export. |
|
||||
| `IDAdresseAnschrift_FS_Anmelder` | `bigint` | yes | Applicant postal address id; no FK was present in the export. |
|
||||
| `IdAdressePerson_FS_Anmelder` | `bigint` | yes | Applicant person id; FK exists. |
|
||||
| `IDAdresse_FS_Agentur` | `bigint` | no | Agency address; FK exists. |
|
||||
| `IDAdresse_FS_Mandant` | `bigint` | no | Tenant/client address; FK exists. |
|
||||
| `LoginWert1` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||
| `LoginWert2` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||
| `LoginWert3` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||
|
||||
Known exported foreign keys:
|
||||
|
||||
- `Buchung.IdAdressePerson_FS_Anmelder -> AdressePerson.IDAdressePerson`
|
||||
- `Buchung.IDAdresse_FS_Agentur -> Adresse.IDAdresse`
|
||||
- `Buchung.IDAdresse_FS_ErsterSachb -> Adresse.IDAdresse`
|
||||
- `Buchung.IDAdresse_FS_LetzterSachb -> Adresse.IDAdresse`
|
||||
- `Buchung.IDAdresse_FS_Mandant -> Adresse.IDAdresse`
|
||||
|
||||
Notable missing FKs in the export:
|
||||
|
||||
- `Buchung.IDAdresse_FS_Anmelder`
|
||||
- `Buchung.IDAdresseAnschrift_FS_Anmelder`
|
||||
- `Buchung.IDReise_FS`
|
||||
|
||||
Treat those as likely relationships but verify against real data.
|
||||
|
||||
### `BuchungTN`
|
||||
|
||||
Booking participant.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDBuchungTN` | `bigint` | no | Participant row id. |
|
||||
| `IDBuchung_FS` | `bigint` | no | Booking header id. |
|
||||
| `IDAdressePerson_FS` | `bigint` | no | Participant person id. |
|
||||
| `IDAdresseAnschrift_FS` | `bigint` | yes | Participant postal address id. |
|
||||
| `Status` | `nvarchar(2)` | no | Participant status; values need business validation. |
|
||||
| `BuchungsDatum` | `datetime` | no | Booking date. |
|
||||
| `StornoStatusKV` | `nvarchar(2)` | yes | Cancellation-related status. |
|
||||
| `StornoDatum` | `datetime` | yes | Cancellation date. |
|
||||
| `Position` | `smallint` | no | Participant position within booking. |
|
||||
|
||||
Known exported foreign keys:
|
||||
|
||||
- `BuchungTN.IDBuchung_FS -> Buchung.IDBuchung`
|
||||
- `BuchungTN.IDAdressePerson_FS -> AdressePerson.IDAdressePerson`
|
||||
|
||||
### `Adresse`
|
||||
|
||||
Customer/address root.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDAdresse` | `bigint` | no | Address/customer id needed by the local user record. |
|
||||
| `SuchName` | `nvarchar(402)` | no | Search/display name. |
|
||||
| `Status` | `nvarchar(2)` | no | Address status; active values need validation. |
|
||||
| `Art` | `tinyint` | no | Address type; values need validation. |
|
||||
| `IDAdresseAnschrift_FS_Versandanschrift` | `bigint` | no | Default shipping/postal address. |
|
||||
| `IDAdressePerson_FS_ReferenzM` | `bigint` | no | Reference person. |
|
||||
| `DatumLöschungDSGVO` | `datetime` | yes | GDPR deletion marker. Exclude rows where this is not null for portal access. |
|
||||
|
||||
### `AdressePerson`
|
||||
|
||||
Person attached to an address.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDAdressePerson` | `bigint` | no | Person id needed by the local user record. |
|
||||
| `IDAdresse_FS` | `bigint` | no | Parent address id. |
|
||||
| `Name` | `nvarchar(200)` | no | Last name. |
|
||||
| `Vorname` | `nvarchar(200)` | yes | First name. |
|
||||
| `Geschlecht` | `nvarchar(2)` | no | Gender/salutation code. |
|
||||
| `Status` | `nvarchar(2)` | no | Person status; active values need validation. |
|
||||
| `Passwort` | `nvarchar(200)` | no | Legacy BusProNet password field. Do not use from `bpn-connect`; password migration remains on the BusProNet XML API until the algorithm is known. |
|
||||
| `Registrierung` | `bit` | no | Registration flag; semantics need validation. |
|
||||
| `IDFremd` | `nvarchar(100)` | no | External id field. |
|
||||
|
||||
Known exported foreign key:
|
||||
|
||||
- `AdressePerson.IDAdresse_FS -> Adresse.IDAdresse`
|
||||
|
||||
### `AdresseKommunikation`
|
||||
|
||||
Communication values for an address/person.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDAdresseKommunikation` | `bigint` | no | Communication row id. |
|
||||
| `IDAdresse_FS` | `bigint` | no | Address id. |
|
||||
| `IDAdressePerson_FS` | `bigint` | no | Person id. No FK was present in the export, but the name strongly implies `AdressePerson.IDAdressePerson`. |
|
||||
| `Wert` | `nvarchar(512)` | no | Communication value, likely email/phone depending on type. |
|
||||
| `WertTelefonie` | `nvarchar(100)` | yes | Phone-specific normalized value. |
|
||||
| `IDArtKommunikation_FS` | `bigint` | no | Communication type. |
|
||||
| `Status` | `nvarchar(2)` | no | Communication status; active values need validation. |
|
||||
| `Newsletter` | `bit` | no | Newsletter flag. |
|
||||
| `DoubleOptIn` | `bit` | no | Double opt-in flag. |
|
||||
|
||||
Known exported foreign keys:
|
||||
|
||||
- `AdresseKommunikation.IDAdresse_FS -> Adresse.IDAdresse`
|
||||
- `AdresseKommunikation.IDArtKommunikation_FS -> ArtKommunikation.IDArtKommunikation`
|
||||
|
||||
Important missing FK:
|
||||
|
||||
- `AdresseKommunikation.IDAdressePerson_FS` is not exported as a FK. Join it to
|
||||
`AdressePerson.IDAdressePerson` only after validating with real data.
|
||||
|
||||
### `ArtKommunikation`
|
||||
|
||||
Communication type lookup.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDArtKommunikation` | `bigint` | no | Type id. |
|
||||
| `Langtext` | `nvarchar(200)` | yes | Long label. |
|
||||
| `Kurztext` | `nvarchar(40)` | yes | Short label. |
|
||||
| `Code` | `nvarchar(20)` | yes | Type code. Find the email code/id here. |
|
||||
| `Status` | `nvarchar(2)` | yes | Type status. |
|
||||
|
||||
Before relying on email lookup queries, run:
|
||||
|
||||
```sql
|
||||
SELECT IDArtKommunikation, Langtext, Kurztext, Code, Status
|
||||
FROM dbo.ArtKommunikation
|
||||
ORDER BY IDArtKommunikation;
|
||||
```
|
||||
|
||||
Then hard-code or configure the email type ids/codes in `bpn-connect`.
|
||||
|
||||
### `AdresseAnschrift`
|
||||
|
||||
Postal address.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IDAdresseAnschrift` | `bigint` | no | Postal address id. |
|
||||
| `IDAdresse_FS` | `bigint` | no | Address id. |
|
||||
| `IDAdressePerson_FS` | `bigint` | yes | Person id. |
|
||||
| `Anrede` | `nvarchar(100)` | yes | Salutation. |
|
||||
| `Name` | `nvarchar(400)` | yes | Postal name. |
|
||||
| `Strasse` | `nvarchar(200)` | no | Street. |
|
||||
| `PLZ` | `nvarchar(20)` | no | Postal code. |
|
||||
| `Ort` | `nvarchar(200)` | no | City. |
|
||||
| `Status` | `nvarchar(2)` | no | Postal address status. |
|
||||
|
||||
No direct FK from `AdresseAnschrift` to `Adresse` or `AdressePerson` was present
|
||||
in the export subset shown by the current FK file. Treat these as likely
|
||||
relationships and validate them with data.
|
||||
|
||||
### `Reise` and `Produkt`
|
||||
|
||||
Travel/product metadata.
|
||||
|
||||
`Reise` important columns:
|
||||
|
||||
- `IDReise`
|
||||
- `IDProdukt_FS`
|
||||
- `Bezeichnung`
|
||||
- `Code`
|
||||
- `TerminVon`
|
||||
- `TerminBis`
|
||||
- `Status`
|
||||
- `StornoDatum`
|
||||
|
||||
`Produkt` important columns:
|
||||
|
||||
- `IDProdukt`
|
||||
- `Bezeichnung`
|
||||
- `Code`
|
||||
- `Suchfeld`
|
||||
- `Status`
|
||||
|
||||
Known exported foreign key:
|
||||
|
||||
- `Reise.IDProdukt_FS -> Produkt.IDProdukt`
|
||||
|
||||
## Relationship Map
|
||||
|
||||
Most relevant joins for portal access:
|
||||
|
||||
```text
|
||||
Buchung.IDBuchung
|
||||
-> BuchungTN.IDBuchung_FS
|
||||
-> BuchungTN.IDAdressePerson_FS
|
||||
-> AdressePerson.IDAdressePerson
|
||||
-> AdressePerson.IDAdresse_FS
|
||||
-> Adresse.IDAdresse
|
||||
```
|
||||
|
||||
Applicant joins:
|
||||
|
||||
```text
|
||||
Buchung.IdAdressePerson_FS_Anmelder
|
||||
-> AdressePerson.IDAdressePerson
|
||||
-> AdressePerson.IDAdresse_FS
|
||||
-> Adresse.IDAdresse
|
||||
```
|
||||
|
||||
Email joins to validate:
|
||||
|
||||
```text
|
||||
AdresseKommunikation.IDAdressePerson_FS
|
||||
-> AdressePerson.IDAdressePerson
|
||||
|
||||
AdresseKommunikation.IDAdresse_FS
|
||||
-> Adresse.IDAdresse
|
||||
|
||||
AdresseKommunikation.IDArtKommunikation_FS
|
||||
-> ArtKommunikation.IDArtKommunikation
|
||||
```
|
||||
|
||||
## Required bpn-connect Endpoints
|
||||
|
||||
All endpoints are private and authenticated by API key:
|
||||
|
||||
```http
|
||||
X-API-Key: {apiKey}
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Keep responses narrow. `bpn-connect` should return only fields needed by the
|
||||
Symfony portal and should never return BusProNet passwords, bank data, credit
|
||||
card data, or broad customer records by default.
|
||||
|
||||
Authentication rule: Symfony owns portal passwords after a user has been
|
||||
bootstrapped locally. The BusProNet legacy password algorithm is currently
|
||||
unknown, so `bpn-connect` must not verify passwords from the database. Keep the
|
||||
existing BusProNet XML password check only for first-time legacy migration. Once
|
||||
the local Symfony password hash exists, all subsequent login/profile refresh
|
||||
logic should use local authentication plus `bpn-connect` read endpoints.
|
||||
|
||||
### `GET /api/bookings/applicant-lookup`
|
||||
|
||||
Purpose: resolve an applicant by email and public booking number. This is the
|
||||
core endpoint for local user creation after a booking and for the portal access
|
||||
request flow.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bookingNumber": "111386",
|
||||
"bookingId": 123456,
|
||||
"addressId": 98765,
|
||||
"personId": 54321,
|
||||
"email": "[email protected]",
|
||||
"firstName": "Example",
|
||||
"lastName": "Customer"
|
||||
}
|
||||
```
|
||||
|
||||
Response rules:
|
||||
|
||||
- Return `200` when `email` belongs to `Buchung.IdAdressePerson_FS_Anmelder`
|
||||
for the given `Buchung.Vorgang`.
|
||||
- Return `404` when there is no matching non-deleted applicant.
|
||||
- Return `409` if the query returns more than one applicant row after
|
||||
deduplication. This should be rare and indicates inconsistent BusProNet data.
|
||||
- Normalize input email by trimming and lowercasing before querying.
|
||||
|
||||
SQL:
|
||||
|
||||
```sql
|
||||
DECLARE @Email nvarchar(512) = LOWER(LTRIM(RTRIM(@email)));
|
||||
DECLARE @Vorgang nvarchar(100) = LTRIM(RTRIM(@bookingNumber));
|
||||
|
||||
SELECT TOP (2)
|
||||
b.Vorgang AS bookingNumber,
|
||||
b.IDBuchung AS bookingId,
|
||||
a.IDAdresse AS addressId,
|
||||
ap.IDAdressePerson AS personId,
|
||||
@Email AS email,
|
||||
ap.Vorname AS firstName,
|
||||
ap.Name AS lastName
|
||||
FROM dbo.Buchung b
|
||||
INNER JOIN dbo.AdressePerson ap
|
||||
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
|
||||
INNER JOIN dbo.Adresse a
|
||||
ON a.IDAdresse = ap.IDAdresse_FS
|
||||
WHERE b.Vorgang = @Vorgang
|
||||
AND a.DatumLöschungDSGVO IS NULL
|
||||
AND a.Status <> 'L'
|
||||
AND ap.Status <> 'L'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.AdresseKommunikation ak
|
||||
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
|
||||
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
|
||||
AND LOWER(LTRIM(RTRIM(ak.Wert))) = @Email
|
||||
AND ak.Status <> 'L'
|
||||
-- TODO: restrict to validated email communication type.
|
||||
-- AND ak.IDArtKommunikation_FS IN (...)
|
||||
)
|
||||
ORDER BY ap.IDAdressePerson;
|
||||
```
|
||||
|
||||
### `GET /api/customers/{addressId}/persons/{personId}`
|
||||
|
||||
Purpose: fetch the personal data for a known BusProNet person. Use this after a
|
||||
local user exists and already stores `addressId` and `personId`. Do not use email
|
||||
as the identifier because multiple `Adresse` rows can share an email address.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/persons/{personId}
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"addressId": 98765,
|
||||
"personId": 54321,
|
||||
"firstName": "Example",
|
||||
"lastName": "Customer",
|
||||
"email": "[email protected]",
|
||||
"genderCode": "F",
|
||||
"title": null,
|
||||
"birthDate": "1980-01-01T00:00:00Z",
|
||||
"nationality": "deutsch",
|
||||
"nationalityCountryName": "Deutschland",
|
||||
"street": "Example Street 1",
|
||||
"postalCode": "12345",
|
||||
"city": "Example City",
|
||||
"addressCountryName": "Deutschland"
|
||||
}
|
||||
```
|
||||
|
||||
Response rules:
|
||||
|
||||
- Return `200` for a non-deleted address/person pair.
|
||||
- Return `404` if the address/person pair does not exist or has `Status = 'L'`.
|
||||
- Return only portal-editable or portal-visible fields.
|
||||
|
||||
SQL:
|
||||
|
||||
```sql
|
||||
DECLARE @AddressId bigint = @addressId;
|
||||
DECLARE @PersonId bigint = @personId;
|
||||
|
||||
SELECT
|
||||
a.IDAdresse AS addressId,
|
||||
ap.IDAdressePerson AS personId,
|
||||
ap.Name AS lastName,
|
||||
ap.Vorname AS firstName,
|
||||
ap.Geschlecht AS genderCode,
|
||||
ap.Titel AS title,
|
||||
ap.Geburtsdatum AS birthDate,
|
||||
ap.Briefanrede AS letterSalutation,
|
||||
ap.Anrede AS salutation,
|
||||
ap.Newsletter AS newsletter,
|
||||
email.Wert AS email,
|
||||
|
||||
manifest.IDArtNationalitaet_FS AS nationalityId,
|
||||
person_nat.Langtext AS nationalityCountryName,
|
||||
person_nat.Kurztext AS nationality,
|
||||
person_nat.Code AS nationalityCode,
|
||||
|
||||
aa.IDAdresseAnschrift AS postalAddressId,
|
||||
aa.Anrede AS postalSalutation,
|
||||
aa.Name AS postalName,
|
||||
aa.Strasse AS street,
|
||||
aa.PLZ AS postalCode,
|
||||
aa.Ort AS city,
|
||||
aa.Ortsteil AS district,
|
||||
aa.IDArtNationalitaet_FS_Land AS addressCountryId,
|
||||
address_country.Langtext AS addressCountryName,
|
||||
address_country.Kurztext AS addressCountryNationality,
|
||||
address_country.Code AS addressCountryCode,
|
||||
|
||||
ad.TelefonOK AS phoneAllowed,
|
||||
ad.MailOK AS mailAllowed,
|
||||
ad.PostOK AS postAllowed
|
||||
FROM dbo.AdressePerson ap
|
||||
INNER JOIN dbo.Adresse a
|
||||
ON a.IDAdresse = ap.IDAdresse_FS
|
||||
OUTER APPLY (
|
||||
SELECT TOP (1)
|
||||
ak.Wert
|
||||
FROM dbo.AdresseKommunikation ak
|
||||
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
|
||||
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
|
||||
AND ak.Status <> 'L'
|
||||
-- TODO: restrict to validated email communication type.
|
||||
-- AND ak.IDArtKommunikation_FS IN (...)
|
||||
ORDER BY ak.IDAdresseKommunikation DESC
|
||||
) email
|
||||
LEFT JOIN dbo.AdresseAnschrift aa
|
||||
ON aa.IDAdresseAnschrift = ap.IDAdresseAnschrift_FS_Versandanschrift
|
||||
AND aa.Status <> 'L'
|
||||
LEFT JOIN dbo.ArtNationalitaet address_country
|
||||
ON address_country.IDArtNationalitaet = aa.IDArtNationalitaet_FS_Land
|
||||
AND COALESCE(address_country.Status, '') <> 'L'
|
||||
OUTER APPLY (
|
||||
SELECT TOP (1)
|
||||
m.IDArtNationalitaet_FS
|
||||
FROM dbo.AdressePersonManifest m
|
||||
WHERE m.IDAdressePerson_FS = ap.IDAdressePerson
|
||||
AND m.IDAdresse_FS = ap.IDAdresse_FS
|
||||
AND COALESCE(m.Status, '') <> 'L'
|
||||
ORDER BY m.IDAdressePersonManifest DESC
|
||||
) manifest
|
||||
LEFT JOIN dbo.ArtNationalitaet person_nat
|
||||
ON person_nat.IDArtNationalitaet = manifest.IDArtNationalitaet_FS
|
||||
AND COALESCE(person_nat.Status, '') <> 'L'
|
||||
LEFT JOIN dbo.AdresseDatenschutz ad
|
||||
ON ad.IdAdresse_FS = a.IDAdresse
|
||||
WHERE a.IDAdresse = @AddressId
|
||||
AND ap.IDAdressePerson = @PersonId
|
||||
AND a.DatumLöschungDSGVO IS NULL
|
||||
AND a.Status <> 'L'
|
||||
AND ap.Status <> 'L';
|
||||
```
|
||||
|
||||
### Optional Debug Endpoint: `GET /api/customers/by-email`
|
||||
|
||||
Purpose: manual investigation only. Email is not unique across `Adresse`, so this
|
||||
endpoint should return a list and should not be used as a local user identifier.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
GET /api/customers/by-email?email={email}
|
||||
```
|
||||
|
||||
Use the same joins as `GET /api/customers/{addressId}/persons/{personId}`, but
|
||||
start from `AdresseKommunikation` and return all non-deleted matching persons.
|
||||
Do not use this for account creation unless the user also provides a booking
|
||||
number or another disambiguating key.
|
||||
|
||||
## Symfony Login And Bootstrap Replacement Plan
|
||||
|
||||
Current Symfony login behavior:
|
||||
|
||||
- Existing local users authenticate against the local Symfony password hash.
|
||||
- Existing legacy local users may still have an encrypted legacy password in the
|
||||
local `User.password` column. Symfony validates it locally once, then replaces
|
||||
it with a Symfony password hash.
|
||||
- If no local user exists, `BpnAuthenticator::bootstrapUser()` currently calls
|
||||
the BusProNet XML API with email and password:
|
||||
- `getPersonalDataWithPassword(email, password)`
|
||||
- `getCrmAttributesWithPassword(email, password)`
|
||||
- After any successful local login, `BpnAuthenticator::refreshBpnSnapshot()`
|
||||
calls the BusProNet XML API by local `addressId` and `personId`:
|
||||
- `getPersonalData(email, addressId, personId)`
|
||||
- `getCrmAttributes(email, addressId, personId)`
|
||||
|
||||
Replacement rule:
|
||||
|
||||
- Keep login password validation local to Symfony.
|
||||
- Keep the current XML password-based bootstrap for users that exist in
|
||||
BusProNet but do not yet have a local Symfony user.
|
||||
- On successful XML legacy verification, Symfony creates a local user with a
|
||||
Symfony password hash from the submitted password.
|
||||
- Future logins use only the local hash and must not call XML for password
|
||||
verification.
|
||||
- Replace post-login snapshot refresh with `bpn-connect` id-based read endpoints.
|
||||
|
||||
### Login Endpoint Usage
|
||||
|
||||
No `bpn-connect` endpoint is needed to authenticate a password.
|
||||
|
||||
Existing local user login should do:
|
||||
|
||||
1. Find local `User` by normalized email.
|
||||
2. Validate local Symfony password hash.
|
||||
3. If the local row still has a legacy encrypted local password, migrate it to a
|
||||
Symfony hash without contacting BusProNet.
|
||||
4. Call `GET /api/customers/{addressId}/persons/{personId}` to refresh profile
|
||||
completeness.
|
||||
5. Call `GET /api/customers/{addressId}/crm-attributes` to refresh roles and
|
||||
hotel codes.
|
||||
|
||||
Missing local user login should do:
|
||||
|
||||
1. Call the existing BusProNet XML password-based bootstrap.
|
||||
2. If valid, create a local `User` with a Symfony hash of the submitted password.
|
||||
3. Store returned `addressId`, `personId`, `roles`, `hotelCodes`, and
|
||||
`profileComplete`.
|
||||
4. Persist and continue login.
|
||||
5. If invalid, fail with the generic invalid-login message.
|
||||
|
||||
Do not add a `bpn-connect` password verification endpoint until the BusProNet
|
||||
password algorithm is known and validated against real examples. The observed
|
||||
database values do not currently match simple MD5/SHA/Base64 derivations.
|
||||
|
||||
### `GET /api/customers/{addressId}/crm-attributes`
|
||||
|
||||
Purpose: replace XML `SelektionCRM` reads used during login refresh and by the
|
||||
OAuth CRM attributes endpoint.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/crm-attributes
|
||||
```
|
||||
|
||||
Minimal response:
|
||||
|
||||
```json
|
||||
{
|
||||
"roles": ["ROLE_CUSTOMER"],
|
||||
"hotelCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
Detailed response may also include selection groups and CRM actions if the
|
||||
current `/api/crm-attributes` response must remain compatible.
|
||||
|
||||
Known role mapping from the XML parser:
|
||||
|
||||
- `Selektionsstamm.IDSelektionsstamm = 1292` selected: `ROLE_ADMIN` and
|
||||
`ROLE_HOUSE_MANAGER`
|
||||
- `Selektionsstamm.IDSelektionsstamm = 1293` selected: `ROLE_MANAGER`
|
||||
- `Selektionsstamm.IDSelektionsstamm = 1070` selected: `ROLE_TEAMER`
|
||||
- selected `Selektionsstamm.Selektion` matching `Hausleitung {CODE}`:
|
||||
`ROLE_HOUSE_MANAGER` and hotel code `{CODE}`
|
||||
- admin users also receive default hotel code `SSL`
|
||||
- no matched role means `ROLE_CUSTOMER`
|
||||
|
||||
These are the CRM's claims. The portal filters them before storing anything (staff-only
|
||||
roles, approval markers), so a proxy must return the claims unfiltered — see
|
||||
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
|
||||
|
||||
Candidate SQL for selected customer selections:
|
||||
|
||||
```sql
|
||||
DECLARE @AddressId bigint = @addressId;
|
||||
|
||||
SELECT
|
||||
sg.IDSelektionsgruppenstamm AS groupId,
|
||||
sg.Selektionsgruppe AS groupLabel,
|
||||
s.IDSelektionsstamm AS selectionId,
|
||||
s.Selektion AS selectionLabel,
|
||||
CAST(1 AS bit) AS selected
|
||||
FROM dbo.Selektionen sel
|
||||
INNER JOIN dbo.Selektionsstamm s
|
||||
ON s.IDSelektionsstamm = sel.IDSelektionsstamm_FS
|
||||
LEFT JOIN dbo.Selektionsgruppe sg_map
|
||||
ON sg_map.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||
LEFT JOIN dbo.Selektionsgruppenstamm sg
|
||||
ON sg.IDSelektionsgruppenstamm = sg_map.IDSelektionsgruppenstamm_FS
|
||||
WHERE sel.IDAdresse_FS = @AddressId
|
||||
AND COALESCE(s.Status, '') <> 'L'
|
||||
AND COALESCE(sg.Status, '') <> 'L'
|
||||
ORDER BY sg.Position, sg.Selektionsgruppe, sg_map.Position, s.Selektion;
|
||||
```
|
||||
|
||||
The Go service can derive `roles` and `hotelCodes` from this result with the
|
||||
mapping above. Before shipping, verify whether XML `SelektionCRM` includes
|
||||
unselected mutable options; if the portal only needs roles/hotel codes, selected
|
||||
rows are enough.
|
||||
|
||||
Candidate SQL for CRM actions if compatibility is required:
|
||||
|
||||
```sql
|
||||
DECLARE @AddressId bigint = @addressId;
|
||||
|
||||
SELECT
|
||||
ca.IDCRMAktion AS id,
|
||||
ca.Code AS code,
|
||||
ca.Bezeichnung AS label,
|
||||
ca.Aktiv AS selected
|
||||
FROM dbo.CRMZuweisung cz
|
||||
INNER JOIN dbo.CRMAktion ca
|
||||
ON ca.IDCRMAktion = cz.IDCRMAktion_FS
|
||||
WHERE cz.IDAdresse_FS = @AddressId
|
||||
AND ca.Status <> 'L'
|
||||
ORDER BY ca.Bezeichnung;
|
||||
```
|
||||
|
||||
This needs data validation against the XML response before compatibility is
|
||||
guaranteed.
|
||||
|
||||
## XML Calls To Replace For Local User Processes
|
||||
|
||||
| Current Symfony call | Current XML purpose | Replacement |
|
||||
| --- | --- | --- |
|
||||
| `getPersonalData(email, addressId, personId)` | Load profile by known user ids. | `GET /api/customers/{addressId}/persons/{personId}` |
|
||||
| `updatePersonalData(email, personalData)` | Persist profile edits. | Future `PATCH /api/customers/{addressId}/persons/{personId}` with transaction-safe updates. |
|
||||
| `getCrmAttributes(email, addressId, personId)` | Refresh roles/hotel codes and expose CRM attributes. | `GET /api/customers/{addressId}/crm-attributes` |
|
||||
| `getPersonalDataWithPassword(email, password)` | Missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
|
||||
| `getCrmAttributesWithPassword(email, password)` | Roles for missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
|
||||
| `register(registrationData)` | Create BusProNet address/person and local user. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
|
||||
| `createAddress(personalData)` | Create BusProNet address/person without mail. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
|
||||
|
||||
Recommended migration sequence:
|
||||
|
||||
1. Keep XML for legacy password bootstrap only.
|
||||
2. Replace booking applicant lookup for guest provisioning and portal access.
|
||||
3. Replace post-login profile/CRM refresh reads.
|
||||
4. Replace personal-data page reads.
|
||||
5. Replace OAuth/userinfo and CRM API reads.
|
||||
6. Replace booking form prefill reads.
|
||||
7. Defer personal-data writes until update rules are validated.
|
||||
8. Defer registration/create-address until the insert graph is known.
|
||||
|
||||
### Phase 1: Keep XML For Legacy Password Bootstrap
|
||||
|
||||
Keep these XML calls temporarily:
|
||||
|
||||
- `getPersonalDataWithPassword(email, password)`
|
||||
- `getCrmAttributesWithPassword(email, password)`
|
||||
|
||||
Scope:
|
||||
|
||||
- Only used when no local Symfony user exists yet.
|
||||
- On success, create the local user with a Symfony password hash from the
|
||||
submitted password.
|
||||
- Store `addressId`, `personId`, `roles`, `hotelCodes`, and `profileComplete`.
|
||||
- Future logins for that user use only the local Symfony password hash.
|
||||
|
||||
Do not implement a `bpn-connect` password verification endpoint until the
|
||||
BusProNet password algorithm is known and verified.
|
||||
|
||||
### Phase 2: Booking Applicant Lookup
|
||||
|
||||
Replace XML/fragile booking lookup plumbing with:
|
||||
|
||||
```http
|
||||
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
|
||||
```
|
||||
|
||||
Symfony integration points:
|
||||
|
||||
- `BookingGuestAccountManager::provisionGuestAccountForBooking()`
|
||||
- `BookingGuestAccountManager::requestPortalAccess()`
|
||||
- booking-created welcome account provisioning
|
||||
|
||||
This endpoint returns only the fields needed for local user creation:
|
||||
|
||||
- `bookingNumber`
|
||||
- `bookingId`
|
||||
- `addressId`
|
||||
- `personId`
|
||||
- `email`
|
||||
- `firstName`
|
||||
- `lastName`
|
||||
|
||||
### Phase 3: Post-login Snapshot Refresh
|
||||
|
||||
Replace XML reads in `BpnAuthenticator::refreshBpnSnapshot()` with:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/persons/{personId}
|
||||
GET /api/customers/{addressId}/crm-attributes
|
||||
```
|
||||
|
||||
Symfony keeps local password validation. `bpn-connect` only refreshes ids,
|
||||
profile completeness, roles, and hotel codes after the password has already been
|
||||
validated locally.
|
||||
|
||||
### Phase 4: Personal-data Page Reads
|
||||
|
||||
Replace XML read in `PersonalDataController::loadPersonalData()` with:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/persons/{personId}
|
||||
```
|
||||
|
||||
Keep `updatePersonalData()` on XML for now. Reads are low-risk; writes require
|
||||
validated BusProNet update semantics.
|
||||
|
||||
### Phase 5: OAuth/Userinfo And CRM Reads
|
||||
|
||||
Replace XML reads in:
|
||||
|
||||
- `UserinfoController`
|
||||
- `CrmAttributeController`
|
||||
|
||||
with:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/persons/{personId}
|
||||
GET /api/customers/{addressId}/crm-attributes
|
||||
```
|
||||
|
||||
### Phase 6: Booking Form Prefill Reads
|
||||
|
||||
Replace XML read in `ParticipantDataPrefiller::prefillApplicantFromUser()` with:
|
||||
|
||||
```http
|
||||
GET /api/customers/{addressId}/persons/{personId}
|
||||
```
|
||||
|
||||
### Phase 7: Defer Personal-data Writes
|
||||
|
||||
Keep XML call:
|
||||
|
||||
- `updatePersonalData(email, personalData)`
|
||||
|
||||
Future replacement:
|
||||
|
||||
```http
|
||||
PATCH /api/customers/{addressId}/persons/{personId}
|
||||
```
|
||||
|
||||
Do this only after write rules are validated for:
|
||||
|
||||
- `AdressePerson`
|
||||
- `AdresseAnschrift`
|
||||
- `AdresseKommunikation`
|
||||
- `AdressePersonManifest`
|
||||
- `AdresseZusatzfeld` or other tables holding height, weight, shoe size, and
|
||||
remarks if those are not in the core person/address tables.
|
||||
|
||||
### Phase 8: Defer Registration And Create-address
|
||||
|
||||
Keep XML calls:
|
||||
|
||||
- `register(registrationData)`
|
||||
- `createAddress(personalData)`
|
||||
|
||||
Future replacement:
|
||||
|
||||
```http
|
||||
POST /api/customers
|
||||
```
|
||||
|
||||
This is higher risk than read replacement because it requires the full BusProNet
|
||||
insert graph, required defaults, and side effects.
|
||||
|
||||
## Candidate SQL: Find Email Communication Type
|
||||
|
||||
Run once during `bpn-connect` development:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
IDArtKommunikation,
|
||||
Langtext,
|
||||
Kurztext,
|
||||
Code,
|
||||
Status
|
||||
FROM dbo.ArtKommunikation
|
||||
WHERE
|
||||
LOWER(COALESCE(Langtext, '')) LIKE '%mail%'
|
||||
OR LOWER(COALESCE(Kurztext, '')) LIKE '%mail%'
|
||||
OR LOWER(COALESCE(Code, '')) LIKE '%mail%'
|
||||
ORDER BY IDArtKommunikation;
|
||||
```
|
||||
|
||||
If the data uses German labels, also inspect all rows manually. The schema alone
|
||||
cannot prove the canonical email type.
|
||||
|
||||
## Candidate SQL: Validate Missing Relationships
|
||||
|
||||
Use these checks before shipping lookup logic:
|
||||
|
||||
```sql
|
||||
-- Check whether AdresseKommunikation.IDAdressePerson_FS is a real person link.
|
||||
SELECT TOP (20)
|
||||
ak.IDAdresseKommunikation,
|
||||
ak.IDAdresse_FS,
|
||||
ak.IDAdressePerson_FS,
|
||||
ap.IDAdressePerson,
|
||||
ap.IDAdresse_FS AS PersonAddressId,
|
||||
ak.Wert
|
||||
FROM dbo.AdresseKommunikation ak
|
||||
LEFT JOIN dbo.AdressePerson ap
|
||||
ON ap.IDAdressePerson = ak.IDAdressePerson_FS
|
||||
WHERE ak.IDAdressePerson_FS <> 0
|
||||
AND ap.IDAdressePerson IS NULL;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Check whether Buchung.IDAdresse_FS_Anmelder matches the applicant person's address.
|
||||
SELECT TOP (100)
|
||||
b.IDBuchung,
|
||||
b.Vorgang,
|
||||
b.IDAdresse_FS_Anmelder,
|
||||
b.IdAdressePerson_FS_Anmelder,
|
||||
ap.IDAdresse_FS AS ApplicantPersonAddressId
|
||||
FROM dbo.Buchung b
|
||||
LEFT JOIN dbo.AdressePerson ap
|
||||
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
|
||||
WHERE b.IDAdresse_FS_Anmelder IS NOT NULL
|
||||
AND ap.IDAdresse_FS <> b.IDAdresse_FS_Anmelder;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Inspect status values before adding active-only predicates.
|
||||
SELECT 'Adresse' AS table_name, Status, COUNT(*) AS rows
|
||||
FROM dbo.Adresse
|
||||
GROUP BY Status
|
||||
UNION ALL
|
||||
SELECT 'AdressePerson', Status, COUNT(*)
|
||||
FROM dbo.AdressePerson
|
||||
GROUP BY Status
|
||||
UNION ALL
|
||||
SELECT 'AdresseKommunikation', Status, COUNT(*)
|
||||
FROM dbo.AdresseKommunikation
|
||||
GROUP BY Status
|
||||
UNION ALL
|
||||
SELECT 'BuchungTN', Status, COUNT(*)
|
||||
FROM dbo.BuchungTN
|
||||
GROUP BY Status
|
||||
ORDER BY table_name, Status;
|
||||
```
|
||||
|
||||
## Indexes To Confirm Or Add
|
||||
|
||||
The current CSV files do not include index definitions. For the lookup endpoint,
|
||||
confirm whether these access paths already exist:
|
||||
|
||||
- `Buchung(Vorgang)` including `IDBuchung`, `IdAdressePerson_FS_Anmelder`
|
||||
- `BuchungTN(IDBuchung_FS)` including `IDAdressePerson_FS`, `Status`
|
||||
- `AdressePerson(IDAdressePerson)` including `IDAdresse_FS`, `Vorname`, `Name`,
|
||||
`Status`
|
||||
- `AdresseKommunikation(IDAdressePerson_FS, Wert)` including `IDAdresse_FS`,
|
||||
`IDArtKommunikation_FS`, `Status`
|
||||
- `AdresseKommunikation(IDAdresse_FS, Wert)` including `IDAdressePerson_FS`,
|
||||
`IDArtKommunikation_FS`, `Status`
|
||||
|
||||
If the database collation is case-sensitive and email lookup must be
|
||||
case-insensitive, avoid `LOWER(Wert)` on large tables unless a computed
|
||||
normalized-email column or suitable index exists.
|
||||
|
||||
## Future Endpoint Candidates
|
||||
|
||||
The XML plumbing can be replaced incrementally by small read endpoints backed by
|
||||
targeted SQL:
|
||||
|
||||
| Endpoint | Purpose | Core tables |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/bookings/lookup` | Resolve email + public booking number to booking/person/address ids. | `Buchung`, `BuchungTN`, `AdressePerson`, `AdresseKommunikation`, `Adresse` |
|
||||
| `GET /api/bookings/{bookingId}` | Fetch booking header/detail by internal id. | `Buchung`, `Reise`, `Produkt`, `BuchungTN` |
|
||||
| `GET /api/bookings/{bookingId}/participants` | Fetch participants for a booking. | `BuchungTN`, `AdressePerson`, `AdresseAnschrift` |
|
||||
| `GET /api/customers/{addressId}/persons/{personId}` | Fetch a minimal customer/person profile. | `Adresse`, `AdressePerson`, `AdresseKommunikation`, `AdresseAnschrift` |
|
||||
|
||||
Keep endpoint responses narrow. Return only ids and fields needed by the Symfony
|
||||
portal so the Go service does not become a broad BusProNet data mirror.
|
||||
|
||||
## Regeneration Notes
|
||||
|
||||
When new catalog exports are added, regenerate this document with:
|
||||
|
||||
1. Columns export from `sys.tables`, `sys.schemas`, `sys.columns`, `sys.types`.
|
||||
2. Foreign key export from `sys.foreign_keys` and related FK columns.
|
||||
3. Primary-key export from `sys.key_constraints`.
|
||||
4. Index export from `sys.indexes`, `sys.index_columns`, and `sys.columns`.
|
||||
|
||||
Primary keys and indexes should be added to the table sections and the lookup
|
||||
query should be revisited once the real access paths are known.
|
||||
@@ -1,477 +0,0 @@
|
||||
# BusProNet XML API — working reference
|
||||
|
||||
Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project
|
||||
uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a
|
||||
broken font encoding, so anything unclear there is flagged rather than guessed at.
|
||||
|
||||
> **The PDF lags the live API.** The vendor has shipped elements since it was written —
|
||||
> `<ausstiege>` (return boarding points) is one confirmed example. So *absent from the PDF* means
|
||||
> "not in this snapshot", never "unsupported". Treat the document as a floor on what the API
|
||||
> accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from
|
||||
> silence.
|
||||
|
||||
Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer
|
||||
different questions, and where they disagree the schema is the record of what happened.
|
||||
|
||||
**Use it for:** what an element is called and what it may contain; which codes a failure can
|
||||
return; which documented capabilities we are not using; and which elements we send that the
|
||||
vendor never documented — those are the ones that can break without warning.
|
||||
|
||||
## Envelope
|
||||
|
||||
Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
|
||||
|
||||
```
|
||||
key = md5( user + password + date("YYYYMMDD") + satz-typ )
|
||||
```
|
||||
|
||||
Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
|
||||
the key before sending, so a hand-built payload can carry a placeholder.
|
||||
|
||||
## Satz types
|
||||
|
||||
**Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`,
|
||||
`AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`,
|
||||
`CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`,
|
||||
`GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`,
|
||||
`KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`,
|
||||
`STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`,
|
||||
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
|
||||
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
|
||||
|
||||
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
|
||||
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
|
||||
Its contract is observed, not specified.
|
||||
|
||||
**Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`,
|
||||
`ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`.
|
||||
|
||||
## BUCHUNG — the create contract
|
||||
|
||||
`<buchungsart>` decides what the call does:
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. |
|
||||
| `Buchung` | Stores the booking. |
|
||||
|
||||
`<status>` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by
|
||||
error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by
|
||||
`982`/`983` when the travel forbids options or enquiries outright.
|
||||
|
||||
### Two accommodation models
|
||||
|
||||
The base payload books accommodation as an ordinary service:
|
||||
|
||||
```xml
|
||||
<unterbringungen>
|
||||
<unterbringung idleistung="22341" anzahl="1" zuordnung="1,2"/>
|
||||
</unterbringungen>
|
||||
```
|
||||
|
||||
For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by
|
||||
`ferienzielunterbringungen`, **and** a root `<idpartner>` added:
|
||||
|
||||
```xml
|
||||
<ferienzielunterbringungen>
|
||||
<ferienzielunterbringung idzimmer="2" kategorie="Ik" idverpflegung="2"
|
||||
anreise="30.01.2018" abreise="06.02.2018"
|
||||
anzahl="1" zuordnung="1,2"/>
|
||||
</ferienzielunterbringungen>
|
||||
…
|
||||
<idpartner>78687</idpartner>
|
||||
```
|
||||
|
||||
> `<idpartner>` is introduced as the element you add when switching to the Ferienziel model, and
|
||||
> `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a
|
||||
> teamer booking with zero `BuchungTNFerienziel` rows still returns `<idpartner>222925</idpartner>`
|
||||
> from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so
|
||||
> either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the
|
||||
> travel. That booking's product has exactly one hotel, so the response cannot distinguish the two;
|
||||
> one read-only query settles it.
|
||||
|
||||
Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen
|
||||
fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see
|
||||
"Validation gate order" below. Room dates are cross-checked against the transport legs (`921`,
|
||||
`923`).
|
||||
|
||||
### Full documented request shape
|
||||
|
||||
```xml
|
||||
<anfrage>
|
||||
<user/><key/><satz typ="BUCHUNG"/>
|
||||
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||
<status>F|O|A</status>
|
||||
<idagentur/> <idcrm/> <idreise/> <idpartner/>
|
||||
<fremdvorgang/> <!-- your own reference, stored as Buchung.FremdVorgang -->
|
||||
<datenschutztfmp/> <!-- ad consent, combinable: T Telefon, F Telefax, M EMail, P Post -->
|
||||
<anmelder>
|
||||
name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum,
|
||||
<anschrift>strasse, plz, ort, ortsteil, land</anschrift>
|
||||
<kommunikation>telefonmobil, email, telefonprivat</kommunikation>
|
||||
<bemerkung/>
|
||||
</anmelder>
|
||||
<teilnehmerliste>
|
||||
<teilnehmer id="1">
|
||||
…same personal fields…
|
||||
<wünsche><unterbringungswunsch/><beförderungswunsch/></wünsche>
|
||||
<aktionscode/>
|
||||
</teilnehmer>
|
||||
</teilnehmerliste>
|
||||
<beförderungen><beförderung idleistung anzahl zuordnung/></beförderungen>
|
||||
<ferienzielunterbringungen>…</ferienzielunterbringungen>
|
||||
<zusatzleistungen><zusatzleistung idleistung anzahl zuordnung/></zusatzleistungen>
|
||||
<zustiege><zustieg idzustieg anzahl zuordnung/></zustiege>
|
||||
<sitzpläne><sitzplan idleistung idsitzplan sitzplätze="1B;1A"/></sitzpläne>
|
||||
<versicherungen><versicherung idversicherung anzahl zuordnung/></versicherungen>
|
||||
<gutscheine><gutschein gutscheinnr einloesecode/></gutscheine>
|
||||
<zuschlaege>
|
||||
<zuschlag text betrag anzahl zuordnung
|
||||
provision altersrabatt gruppenrabatt frühbucherrabatt bzberechnung/>
|
||||
</zuschlaege>
|
||||
<zahlung idzahlungsart bezeichnung art>
|
||||
<bankverbindung kreditinstitut bic iban kontoinhaber/>
|
||||
</zahlung>
|
||||
</anfrage>
|
||||
```
|
||||
|
||||
`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout.
|
||||
|
||||
### Responses
|
||||
|
||||
`buchungsart=Anfrage` → `<buchung>möglich</buchung>` plus `<preise>`: one `<preis>` per line with
|
||||
`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`,
|
||||
`preis`, `gesamtpreis`, `id`; then `<gesamtpreis>` and `<zahlungsbedingungen>` with `<anzahlung>`
|
||||
and `<restzahlung>` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT`
|
||||
accommodation, `SON` other, `ERM` discount, `VER` insurance.
|
||||
|
||||
`buchungsart=Buchung` → `<buchung>erfolgt</buchung>`, `<vorgang>`, `<rechnung>`, `<idbuchung>`,
|
||||
`<email>`, `<pdfpasswort>`.
|
||||
|
||||
### Validation gate order (measured)
|
||||
|
||||
Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real
|
||||
room-less teamer booking (Vorgang 110628) element for element:
|
||||
|
||||
1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being
|
||||
valid. Nothing further is evaluated.
|
||||
2. **Personal data**, once a room is present → `650` with the field list *"Fehlende
|
||||
Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse,
|
||||
Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**.
|
||||
|
||||
So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the
|
||||
personal fields whether or not it could resolve them from the ids. Echoing the record back
|
||||
(`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is
|
||||
genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003
|
||||
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
||||
`telefonprivat` and no `nationalitaet`.
|
||||
|
||||
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
|
||||
real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
|
||||
above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
|
||||
is stricter than what BusPro applies to ordinary participants.
|
||||
|
||||
**The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
|
||||
`teilnehmer` needed one once a room was present, children included: without it the answer was
|
||||
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
|
||||
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
|
||||
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
|
||||
Anmelder) all pass with an unchanged price. A committed `Buchung` (status `A`) with an email-less child
|
||||
goes through as well, both with a travelling Anmelder and with one who does not travel. BusPro now accepts a participant without an email at any age,
|
||||
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
|
||||
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
|
||||
without an email can be booked there.
|
||||
|
||||
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
|
||||
for their children) returns `möglich` with the same price as the control where the Anmelder is
|
||||
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
|
||||
been measured yet.
|
||||
|
||||
Note `650` again: both of those are hard rejections delivered under the code
|
||||
`Notification::isError()` treats as success.
|
||||
|
||||
## BUCHUNGAENDERUNG — the edit contract
|
||||
|
||||
Structurally identical to `BUCHUNG` plus `<idbuchung>`, and the responses follow `buchungsart`
|
||||
the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
||||
|
||||
```xml
|
||||
<änderungen>
|
||||
<änderung art="anzahl_teilnehmer" möglich="False"/>
|
||||
<änderung art="beförderung" möglich="False"/>
|
||||
<änderung art="zustieg" möglich="False"/>
|
||||
<änderung art="sitztplatz" möglich="False"/> <!-- vendor's typo -->
|
||||
<änderung art="unterbringung" möglich="False"/>
|
||||
<änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/>
|
||||
<änderung art="teilnehmerdaten" möglich="False"/>
|
||||
</änderungen>
|
||||
```
|
||||
|
||||
## How this app maps onto the contract
|
||||
|
||||
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
|
||||
`addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
|
||||
|
||||
| Documented element | Sent on create | Sent on edit | Notes |
|
||||
|---|---|---|---|
|
||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
|
||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||
| `anmelder` | yes | yes | Create builds it from participant[0], or from `BookingDto::$applicant` when the applicant does not travel; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
||||
| `teilnehmerliste` | yes | yes | |
|
||||
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
||||
| `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` |
|
||||
| `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` |
|
||||
| `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton |
|
||||
| `versicherungen` | yes | yes | |
|
||||
| `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` |
|
||||
| `zahlung` + `bankverbindung` | yes | yes | |
|
||||
| `idcrm` | no | no | |
|
||||
| `fremdvorgang` | no | no | Would give our own bookings a stable external reference |
|
||||
| `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead |
|
||||
| `sitzpläne` | no | no | See Seats |
|
||||
| `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount |
|
||||
|
||||
### Newer than the PDF
|
||||
|
||||
These are absent from the document but work against the live API — vendor additions that
|
||||
post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the
|
||||
gaps in this reference are known.
|
||||
|
||||
| Element | Where | Note |
|
||||
|---|---|---|
|
||||
| `<ausstiege><ausstieg idzustieg …/></ausstiege>` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only `<zustiege>`. |
|
||||
| `<status>` inside `<teilnehmer>` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. |
|
||||
| `<login>J</login>` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. |
|
||||
| `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. |
|
||||
|
||||
When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a
|
||||
reading of this file.
|
||||
|
||||
### Caveat: the edit flow replays a derived status
|
||||
|
||||
`buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed
|
||||
from the response's root `<status>` (`BookingParser.php:38`). But `Buchung` has **no status
|
||||
column** — the root value is an aggregate over `BuchungTN.Status`, the only real store
|
||||
(`<status>F</status>` beside `<status_teilnehmer>F/F/…/F</status_teilnehmer>` in
|
||||
`tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one
|
||||
status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed
|
||||
booking before relying on it.
|
||||
|
||||
### Gap: edit never dry-runs
|
||||
|
||||
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
|
||||
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
|
||||
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
|
||||
price *before* mutating a live booking.
|
||||
|
||||
## Seats
|
||||
|
||||
`SITZPLATZ` asks BusPro to pick seats:
|
||||
|
||||
```xml
|
||||
<satz typ="SITZPLATZ"/>
|
||||
<idreise/><idleistung_hin/><idleistung_rueck/><anzahl_personen/>
|
||||
<wunsch_vornemittehinten>V|M|H</wunsch_vornemittehinten>
|
||||
<wunsch_linksrechts>L|R</wunsch_linksrechts>
|
||||
<wunsch_fenstergang>F|G</wunsch_fenstergang>
|
||||
```
|
||||
|
||||
Response: one `<sitzplan>` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`,
|
||||
`richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where
|
||||
seats carry surcharges the response adds `<zuschläge>` (`id`, `bezeichnung`, `preis`, colours) and
|
||||
the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure
|
||||
instead of a picture.
|
||||
|
||||
Chosen seats go back into a booking via `<sitzpläne>`, landing in
|
||||
`BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei
|
||||
Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz
|
||||
ermittelt werden`.
|
||||
|
||||
## KUNDENKONTO
|
||||
|
||||
`<art>`: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`,
|
||||
`SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`,
|
||||
`Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`,
|
||||
`Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`.
|
||||
|
||||
`Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` —
|
||||
the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a
|
||||
`<personen>` list with `<ausweis>` data and answers `änderung`. `Versicherungspolice` returns
|
||||
per-policy document URLs.
|
||||
|
||||
## AGENTURKONTO
|
||||
|
||||
`<art>`: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`,
|
||||
`OptionInFest` — the last converts a held option into a firm booking, answering
|
||||
`<optioninfestumgewandelt>true</optioninfestumgewandelt>`. `Vorgangdruck` takes `idbuchung`,
|
||||
`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return
|
||||
base64 PDFs.
|
||||
|
||||
## Availability and payment
|
||||
|
||||
`VERFUEGBARKEIT` takes `<idreise>` plus a `<leistungen><leistung id="…"/></leistungen>` filter and
|
||||
answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `preis`,
|
||||
`preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports
|
||||
`buchungstatusmoeglich` and `pflicht` but is undocumented.
|
||||
|
||||
`ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking
|
||||
(`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory).
|
||||
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
|
||||
`anzahlung` or errors `1011`-`1018` follow.
|
||||
|
||||
## Selling vouchers (Kaufgutschein)
|
||||
|
||||
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
|
||||
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
|
||||
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
|
||||
|
||||
| Satz | Request | Response |
|
||||
|---|---|---|
|
||||
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
|
||||
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
|
||||
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
|
||||
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
|
||||
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
|
||||
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
|
||||
|
||||
```xml
|
||||
<satz typ="GUTSCHEINBUCHUNG"/>
|
||||
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||
<gutscheinart>K</gutscheinart>
|
||||
<idgutscheinvorlage>3</idgutscheinvorlage>
|
||||
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
|
||||
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
|
||||
<kaufpreis>25</kaufpreis>
|
||||
<text/> <!-- Zusatztext; the live shop sends it empty -->
|
||||
<rechnungsempfänger>
|
||||
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
|
||||
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
|
||||
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
|
||||
</rechnungsempfänger>
|
||||
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
|
||||
```
|
||||
|
||||
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
|
||||
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
|
||||
live shop also collects a business phone, whose element name is not documented anywhere, so the
|
||||
flow does not collect it.
|
||||
|
||||
### Measured on the test system (2026-10-01)
|
||||
|
||||
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
|
||||
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
|
||||
issued. `idagentur` is not required (619/620 never fired).
|
||||
|
||||
| Variant | Answer |
|
||||
|---|---|
|
||||
| Valid (control), 25 € | `möglich` |
|
||||
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
|
||||
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
|
||||
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
|
||||
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
|
||||
| `empfaenger` missing | `605 Empfängername fehlt` |
|
||||
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
|
||||
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
|
||||
| `strasse` empty | `650 … Strasse` |
|
||||
| `anrede` Herr, `telefonmobil` set | `möglich` |
|
||||
|
||||
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
|
||||
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
|
||||
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
|
||||
shop, which suggests they are shared with production; confirm on the first production read.
|
||||
|
||||
## Error codes
|
||||
|
||||
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
|
||||
runtime.
|
||||
|
||||
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
|
||||
> carrier whose entire content is substituted. `Notification::isError()`
|
||||
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
|
||||
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
|
||||
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
|
||||
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
|
||||
|
||||
**Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp ·
|
||||
103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler
|
||||
|
||||
**Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt ·
|
||||
301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu
|
||||
IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt ·
|
||||
306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden ·
|
||||
402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden
|
||||
|
||||
**Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und
|
||||
EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine
|
||||
Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen ·
|
||||
870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail,
|
||||
PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#)
|
||||
|
||||
**Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status
|
||||
fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden ·
|
||||
908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise ·
|
||||
911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen
|
||||
fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen
|
||||
fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise
|
||||
fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt ·
|
||||
**923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise
|
||||
zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen ·
|
||||
931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei
|
||||
Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt ·
|
||||
951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben ·
|
||||
960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann
|
||||
nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist
|
||||
fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht
|
||||
zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar ·
|
||||
985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto ·
|
||||
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
|
||||
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
|
||||
|
||||
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
|
||||
605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
|
||||
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
|
||||
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
|
||||
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt ·
|
||||
631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig ·
|
||||
**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum
|
||||
ungültig**
|
||||
|
||||
633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means
|
||||
the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`):
|
||||
a test promo whose booking period was being edited answered 633 and was accepted once the period
|
||||
covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was
|
||||
returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/
|
||||
`TerminBis`. What it actually compares has not been established.
|
||||
|
||||
## Applying this: the teamer placeholder booking
|
||||
|
||||
**Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking
|
||||
(travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand,
|
||||
pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns
|
||||
`916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate
|
||||
and moves on to `650`, which proves accommodation was the only structural objection.
|
||||
|
||||
Two blockers that were *not* the problem:
|
||||
|
||||
- **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982`
|
||||
refusal seen earlier is per travel, not a global setting.
|
||||
- **`NurFahrt` never came up** — the request never got far enough for it to matter.
|
||||
|
||||
What the real booking shows about how the office builds one, worth reproducing if the work moves
|
||||
to a direct write:
|
||||
|
||||
- `<idpartner>` **is** present despite there being no accommodation — 222925 is the ordinary
|
||||
hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen",
|
||||
the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation
|
||||
partner. Whether the booking stores it or the response derives it is still open.
|
||||
- Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003,
|
||||
with an empty `<anschrift/>`.
|
||||
- `<wünsche><beförderungswunsch>` carries the role label — "Reiseleitung + Busbegleitung".
|
||||
- Per participant, `<optionsdatum>` sets the option expiry.
|
||||
- The total is driven to zero with a **manual `<zuschlag>`**: Ortstaxe 44,00 + Keycard-Pfand 10,80
|
||||
+ Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `<zuschlaege>` is part of
|
||||
the documented `BUCHUNG` payload, so this part *is* expressible over XML.
|
||||
|
||||
See `.ddev/plans/teamer-placeholder-booking-plan.md`.
|
||||