feat: forms on TYPO3 13, loaded into the static pages with htmx
The static export replaced every EXT:form form with a contact notice. The forms are back, rendered by TYPO3 13 and embedded in the static pages: - Page type 1704 (/<page>/form) renders only the form plugin of a page, inside a .form-island element. Every form posts to that URL (renderingOptions.additionalParams), because on www the page URL is a static file that never reaches TYPO3. htmx swaps the island in place: validation errors, multi-step pages and confirmations. A redirect to an htmx request becomes HX-Redirect (HtmxRedirectMiddleware), so Redirect finishers send the browser to the thank-you page. - While a request runs, an overlay with a spinner (as in MyE&P) covers the form. Styled inside the island: the static pages load the CSS of the TYPO3 10 export. - The bw_captcha image comes from /<page>/captcha for the same reason. Both suffixes are in the PageType enhancer of every site with forms (semesterende gets one with default '' to keep its URLs). - Ported from TYPO3 10: form setup, templates and partials, the MyE&P address finisher, the job application finisher with the MyE&P Team API client (epTeamApi* in secrets.php), the page URL/referrer finisher with ReferrerMiddleware (?ref=), the e-mail layout without the system footer, bw_captcha 5 and form_element_linked_checkbox 6. The address check forms are dropped. - German language packs (EXT:form messages): availableLanguages = de, fetched on every deploy (typo3:language:update). - The checkbox partials passed null to VHS' substring, which PHP 8.5 reports as deprecated. - DDEV no longer turns deprecations into exceptions, matching production: EXT:form's RenderFormValueViewHelper uses null as an array offset for empty optional fields (core bug, also in 13.4.37). The static pages get their placeholders from the exporter in EP/website, see .ddev/plans/static-export-form-islands.patch (local only). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
59e4a2d49b
commit
925e24397b
54 files changed
+2500
-3
No files matched your search
Generated
+272
-1
@@ -128,6 +128,91 @@
|
||||
},
|
||||
"time": "2026-04-05T21:06:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "blueways/bw-captcha",
|
||||
"version": "5.4.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maikschneider/bw_captcha.git",
|
||||
"reference": "74c5bbce91285b146c3c8dbbb2916783c0227c38"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maikschneider/bw_captcha/zipball/74c5bbce91285b146c3c8dbbb2916783c0227c38",
|
||||
"reference": "74c5bbce91285b146c3c8dbbb2916783c0227c38",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-gd": "*",
|
||||
"gregwar/captcha": "^2.1",
|
||||
"maikschneider/steganography": "^1.0",
|
||||
"php": "^8.2",
|
||||
"typo3/cms-core": "^12.0 || ^13.0 || ^14.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"armin/editorconfig-cli": "^2.0",
|
||||
"bk2k/bootstrap-package": "^15.0 || ^16.0",
|
||||
"derhansen/sf_event_mgt": "^7.6 || ^8.0 || ^9.0",
|
||||
"eliashaeussler/typo3-vendor-bundler": "^4.0",
|
||||
"ergebnis/composer-normalize": "^2.44",
|
||||
"friendsofphp/php-cs-fixer": "^3.12",
|
||||
"helhum/typo3-console": "^7.0 || ^8.0 || ^9.0",
|
||||
"helmich/typo3-typoscript-lint": "^3.2",
|
||||
"move-elevator/composer-translation-validator": "^1.3.1",
|
||||
"saschaegerer/phpstan-typo3": "^2.1.1 || ^3.0.0",
|
||||
"ssch/typo3-rector": "^3.9.0",
|
||||
"typo3/cms-base-distribution": "^12.4 || ^13.4 || ^14.0",
|
||||
"typo3/cms-lowlevel": "^12.4 || ^13.4 || ^14.0"
|
||||
},
|
||||
"type": "typo3-cms-extension",
|
||||
"extra": {
|
||||
"typo3/cms": {
|
||||
"web-dir": "public",
|
||||
"extension-key": "bw_captcha",
|
||||
"cms-package-dir": "{$vendor-dir}/typo3/cms",
|
||||
"vendor-libraries": {
|
||||
"root-path": "Resources/Private/Libs",
|
||||
"sbom-file": "Resources/Private/Libs/sbom.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Gregwar\\": [
|
||||
"Resources/Private/Libs/vendor/gregwar/captcha/src/Gregwar"
|
||||
],
|
||||
"Blueways\\BwCaptcha\\": [
|
||||
"Classes"
|
||||
],
|
||||
"MaikSchneider\\Steganography\\": [
|
||||
"Resources/Private/Libs/vendor/maikschneider/steganography/src"
|
||||
]
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"Resources/Private/Libs/vendor/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-2.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maik Schneider",
|
||||
"email": "[email protected]",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Captcha element with audio support for TYPO3 form components. The captcha generation does not rely on Google or third-party integrations.",
|
||||
"keywords": [
|
||||
"typo3"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maikschneider/bw_captcha/issues",
|
||||
"source": "https://github.com/maikschneider/bw_captcha/tree/5.4.2"
|
||||
},
|
||||
"time": "2026-10-20T05:07:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "christian-riesen/base32",
|
||||
"version": "2.0.1",
|
||||
@@ -865,16 +950,19 @@
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "./packages/ep_theme",
|
||||
"reference": "f3e425fca030011066b3d2e6a25a756941885b0d"
|
||||
"reference": "3848f9e276e8ece16ce7755556c4ef033c93f357"
|
||||
},
|
||||
"require": {
|
||||
"b13/container": "^4.1",
|
||||
"blueways/bw-captcha": "^5.4",
|
||||
"ep/ep-products": "@dev",
|
||||
"php": "^8.2",
|
||||
"tritum/form-element-linked-checkbox": "^6.0",
|
||||
"typo3/cms-core": "^13.4",
|
||||
"typo3/cms-extbase": "^13.4",
|
||||
"typo3/cms-fluid": "^13.4",
|
||||
"typo3/cms-fluid-styled-content": "^13.4",
|
||||
"typo3/cms-form": "^13.4",
|
||||
"typo3/cms-frontend": "^13.4"
|
||||
},
|
||||
"type": "typo3-cms-extension",
|
||||
@@ -1046,6 +1134,68 @@
|
||||
},
|
||||
"time": "2026-07-23T10:57:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "gregwar/captcha",
|
||||
"version": "v2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gregwar/Captcha.git",
|
||||
"reference": "c1e67b3d7ac4f873f785daa366051d7c73d19ca0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Gregwar/Captcha/zipball/c1e67b3d7ac4f873f785daa366051d7c73d19ca0",
|
||||
"reference": "c1e67b3d7ac4f873f785daa366051d7c73d19ca0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-mbstring": "*",
|
||||
"php": "^8.2",
|
||||
"symfony/finder": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.49",
|
||||
"phpstan/phpstan": "2.1.17",
|
||||
"phpstan/phpstan-phpunit": "2.0.6",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"squizlabs/php_codesniffer": "4.0.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Gregwar\\": "src/Gregwar"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Grégoire Passault",
|
||||
"email": "[email protected]",
|
||||
"homepage": "http://www.gregwar.com/"
|
||||
},
|
||||
{
|
||||
"name": "Jeremy Livingston",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
],
|
||||
"description": "Captcha generator",
|
||||
"homepage": "https://github.com/Gregwar/Captcha",
|
||||
"keywords": [
|
||||
"bot",
|
||||
"captcha",
|
||||
"spam"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Gregwar/Captcha/issues",
|
||||
"source": "https://github.com/Gregwar/Captcha/tree/v2.1.1"
|
||||
},
|
||||
"time": "2026-08-27T10:37:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "8.3.0",
|
||||
@@ -1591,6 +1741,56 @@
|
||||
},
|
||||
"time": "2025-10-31T12:28:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maikschneider/steganography",
|
||||
"version": "v1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maikschneider/steganography.git",
|
||||
"reference": "6a796c6367d38ac06837d309fba78ffaa4738681"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maikschneider/steganography/zipball/6a796c6367d38ac06837d309fba78ffaa4738681",
|
||||
"reference": "6a796c6367d38ac06837d309fba78ffaa4738681",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-gd": "*",
|
||||
"ext-zlib": "*",
|
||||
"symfony/options-resolver": ">4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.12",
|
||||
"php-coveralls/php-coveralls": "^2.0",
|
||||
"phpunit/phpunit": "^10.3",
|
||||
"rector/rector": "^0.18.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MaikSchneider\\Steganography\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maik Schneider",
|
||||
"email": "[email protected]"
|
||||
}
|
||||
],
|
||||
"description": "Simple implementation of Steganography (Hiding a hidden message within an image)",
|
||||
"keywords": [
|
||||
"steganography"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/maikschneider/steganography/tree/v1.0.2"
|
||||
},
|
||||
"time": "2023-10-08T10:39:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.11.0",
|
||||
@@ -6217,6 +6417,77 @@
|
||||
],
|
||||
"time": "2026-10-12T12:34:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tritum/form-element-linked-checkbox",
|
||||
"version": "v6.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tritum/form_element_linked_checkbox.git",
|
||||
"reference": "cad79670c59d7d52f7cde0d74ab10e9b5e6c78fa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tritum/form_element_linked_checkbox/zipball/cad79670c59d7d52f7cde0d74ab10e9b5e6c78fa",
|
||||
"reference": "cad79670c59d7d52f7cde0d74ab10e9b5e6c78fa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"typo3/cms-core": "^13.4 || ^14.1",
|
||||
"typo3/cms-form": "^13.4 || ^14.1",
|
||||
"typo3/cms-frontend": "^13.4 || ^14.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"armin/editorconfig-cli": "^2.2",
|
||||
"ergebnis/composer-normalize": "^2.23",
|
||||
"helmich/typo3-typoscript-lint": "^3.1",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"saschaegerer/phpstan-typo3": "^1.0",
|
||||
"typo3/coding-standards": "^0.8.0"
|
||||
},
|
||||
"type": "typo3-cms-extension",
|
||||
"extra": {
|
||||
"typo3/cms": {
|
||||
"extension-key": "form_element_linked_checkbox"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TRITUM\\FormElementLinkedCheckbox\\": "Classes/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-2.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Björn Jacob",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://www.tritum.de",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Elias Häußler",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://haeussler.dev",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "dreistrom.land",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://dreistrom.land",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Adds a new form element which allows the editor to create a checkbox with a linked label text. This is an extension for TYPO3 CMS.",
|
||||
"homepage": "https://github.com/tritum/form_element_linked_checkbox",
|
||||
"support": {
|
||||
"issues": "https://github.com/tritum/form_element_linked_checkbox/issues",
|
||||
"source": "https://github.com/tritum/form_element_linked_checkbox/tree/v6.0.0"
|
||||
},
|
||||
"time": "2026-02-24T09:59:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "typo3/class-alias-loader",
|
||||
"version": "v1.2.2",
|
||||
|
||||
@@ -282,6 +282,8 @@ routeEnhancers:
|
||||
/: 0
|
||||
/json: 1701
|
||||
/html: 1702
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
.json: 1803
|
||||
.ical: 1710
|
||||
sitemap.xml: 1533906435
|
||||
|
||||
@@ -79,6 +79,14 @@ routeEnhancers:
|
||||
hotel_uid: \d+
|
||||
date_from: '\d{4}\-\d{2}\-\d{2}'
|
||||
date_to: '\d{4}\-\d{2}\-\d{2}'
|
||||
# Only for the form and captcha page types; default '' keeps the URLs without trailing slash
|
||||
PageTypeSuffix:
|
||||
type: PageType
|
||||
default: ''
|
||||
index: ''
|
||||
map:
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
routes:
|
||||
-
|
||||
route: robots.txt
|
||||
|
||||
@@ -66,6 +66,8 @@ routeEnhancers:
|
||||
/: 0
|
||||
/json: 1701
|
||||
/html: 1702
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
sitemap.xml: 1533906435
|
||||
yoast-snippetpreview.json: 1480321830
|
||||
routes:
|
||||
|
||||
@@ -50,6 +50,8 @@ routeEnhancers:
|
||||
/: 0
|
||||
/json: 1701
|
||||
/html: 1702
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
sitemap.xml: 1533906435
|
||||
yoast-snippetpreview.json: 1480321830
|
||||
routes:
|
||||
|
||||
@@ -50,6 +50,8 @@ routeEnhancers:
|
||||
/: 0
|
||||
/json: 1701
|
||||
/html: 1702
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
sitemap.xml: 1533906435
|
||||
yoast-snippetpreview.json: 1480321830
|
||||
routes:
|
||||
|
||||
@@ -50,6 +50,8 @@ routeEnhancers:
|
||||
/: 0
|
||||
/json: 1701
|
||||
/html: 1702
|
||||
/form: 1704
|
||||
/captcha: 3413
|
||||
sitemap.xml: 1533906435
|
||||
yoast-snippetpreview.json: 1480321830
|
||||
routes:
|
||||
|
||||
@@ -35,8 +35,11 @@ if (getenv('IS_DDEV_PROJECT') == 'true') {
|
||||
'trustedHostsPattern' => '.*.*',
|
||||
'devIPmask' => '*',
|
||||
'displayErrors' => 1,
|
||||
// Warnings and deprecations are exceptions locally: they are how PHP 7.4 leftovers show up
|
||||
'exceptionalErrors' => 12290,
|
||||
// Warnings are exceptions locally: they are how PHP 7.4 leftovers show up. Deprecations
|
||||
// are not (production neither): PHP 8.5 deprecations in vendor code, e.g. EXT:form's
|
||||
// RenderFormValueViewHelper, would break requests that work in production. They are
|
||||
// still written to the deprecation log.
|
||||
'exceptionalErrors' => E_WARNING | E_RECOVERABLE_ERROR,
|
||||
],
|
||||
'BE' => [
|
||||
'debug' => true,
|
||||
|
||||
@@ -28,6 +28,9 @@ return [
|
||||
'myEpApiClientSecret' => '',
|
||||
'myEpApiUrlAuthorize' => '',
|
||||
'myEpApiUrlToken' => '',
|
||||
// MyE&P Team: job application forms (ApplicationApiFinisher), POST /api/application-forms
|
||||
'epTeamApiBaseUrl' => '',
|
||||
'epTeamApiKey' => '',
|
||||
// ep:groups-swiss (cron, daily): availability of the group houses on groups.swiss.
|
||||
// Production only; the keys are in the TYPO3 10 scheduler tasks 16-21.
|
||||
// 'groupsSwissHouses' => [
|
||||
|
||||
@@ -25,6 +25,13 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
'EXTCONF' => [
|
||||
'lang' => [
|
||||
'availableLanguages' => [
|
||||
'de',
|
||||
],
|
||||
],
|
||||
],
|
||||
'EXTENSIONS' => [
|
||||
'backend' => [
|
||||
'backendFavicon' => '',
|
||||
|
||||
@@ -109,6 +109,7 @@ task('host:check', function () {
|
||||
'static/ep-reisen/fileadmin links there' => 'test "$(readlink -f /usr/home/eptypo3/public_html/static/ep-reisen/fileadmin)" = "$(readlink -f {{fileadmin_path}})" && echo yes',
|
||||
'free disk space' => 'df -h /usr/home/eptypo3 | tail -1',
|
||||
'secrets.php in place' => 'test -s {{deploy_path}}/shared/config/system/secrets.php && {{bin/php}} -r \'exit(is_array(require "{{deploy_path}}/shared/config/system/secrets.php") ? 0 : 1);\' && echo yes',
|
||||
'TYPO3 translation server reachable' => 'curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://localize.typo3.org/xliff/ | grep -v "^000$"',
|
||||
'MyE&P reachable' => 'curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://my.ep-reisen.de/api/last-update',
|
||||
];
|
||||
$failed = false;
|
||||
@@ -152,6 +153,11 @@ task('typo3:extension:setup', function () {
|
||||
run('{{bin/php}} {{release_path}}/{{bin/typo3}} extension:setup');
|
||||
});
|
||||
|
||||
desc('TYPO3 - German language packs (EXT:form validation messages, among others) into the release');
|
||||
task('typo3:language:update', function () {
|
||||
run('{{bin/php}} {{release_path}}/{{bin/typo3}} language:update de --no-progress');
|
||||
});
|
||||
|
||||
desc('TYPO3 - Clear all caches');
|
||||
task('typo3:cache:flush', function () {
|
||||
run('{{bin/php}} {{release_path}}/{{bin/typo3}} cache:flush');
|
||||
@@ -179,6 +185,7 @@ task('deploy', [
|
||||
'typo3:secrets:check',
|
||||
// The schema has to exist before the new code starts serving traffic (adds and changes only).
|
||||
'typo3:extension:setup',
|
||||
'typo3:language:update',
|
||||
// deploy:publish == deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success
|
||||
'deploy:publish',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\EpTeam;
|
||||
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* The MyE&P Team application (Symfony). Separate from EP\EpProducts\MyEP\ApiClient on purpose:
|
||||
* this is a different application on its own host, authenticated with a shared key in a header
|
||||
* instead of the OAuth client-credentials flow MyE&P uses, so it shares neither the base URI nor
|
||||
* the token handling.
|
||||
*/
|
||||
class ApiClient implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
/**
|
||||
* Applied when extension configuration does not override them. Seconds.
|
||||
*/
|
||||
private const DEFAULT_TIMEOUT = 5;
|
||||
private const DEFAULT_MAX_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Status codes the endpoint answers a stored submission with. A conflict is included for the
|
||||
* dedupe the team application may add later - it is not returned today - because a
|
||||
* double-clicked submit button is not something to report.
|
||||
*/
|
||||
private const ACCEPTED_STATUS_CODES = [
|
||||
Response::HTTP_OK,
|
||||
Response::HTTP_CREATED,
|
||||
Response::HTTP_ACCEPTED,
|
||||
Response::HTTP_NO_CONTENT,
|
||||
Response::HTTP_CONFLICT,
|
||||
];
|
||||
|
||||
/**
|
||||
* How much of a rejected response is carried into the exception message. A 400 names the
|
||||
* offending field in its body, which is the only way to tell from a log why a submission
|
||||
* was refused.
|
||||
*/
|
||||
private const ERROR_BODY_EXCERPT_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Shared, like the MyE&P client: Symfony's CurlHttpClient keeps its connection pool and TLS
|
||||
* session cache per instance. The API key is a constant, so unlike a bearer token it can be
|
||||
* a client default header and the instance can live as long as the request.
|
||||
*/
|
||||
private static ?HttpClientInterface $httpClient = null;
|
||||
|
||||
private ?array $configuration = null;
|
||||
|
||||
/**
|
||||
* Posts a submitted TYPO3 form to POST /api/application-forms.
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function submitApplicationForm(array $payload): void
|
||||
{
|
||||
$uri = 'api/application-forms';
|
||||
|
||||
// 'json' also sets the Content-Type the endpoint requires - without it the body is not
|
||||
// read as JSON and the answer is a 400.
|
||||
$response = $this->request('POST', $uri, [
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
$statusCode = $this->readStatusCode($response, 'POST', $uri);
|
||||
|
||||
if (false === in_array($statusCode, self::ACCEPTED_STATUS_CODES, true)) {
|
||||
$message = sprintf(
|
||||
'EP Team API returned unexpected status %d for POST %s: %s',
|
||||
$statusCode,
|
||||
$uri,
|
||||
$this->readErrorBody($response)
|
||||
);
|
||||
$this->logWarning($message);
|
||||
|
||||
throw new ApiException($message, $statusCode);
|
||||
}
|
||||
|
||||
$this->logAcceptedSubmission($response, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function request(string $method, string $uri, array $options): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $this->getHttpClient()->request($method, $uri, $options);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$message = sprintf('EP Team API transport error for %s %s: %s', $method, $uri, $e->getMessage());
|
||||
$this->logError($message, ['exception' => $e]);
|
||||
|
||||
throw new ApiException($message, 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading the status completes the transfer, so this is also what turns the lazy response
|
||||
* returned by the client into a finished request.
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function readStatusCode(ResponseInterface $response, string $method, string $uri): int
|
||||
{
|
||||
try {
|
||||
return $response->getStatusCode();
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$message = sprintf('EP Team API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
|
||||
$this->logError($message, ['exception' => $e]);
|
||||
|
||||
throw new ApiException($message, 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function readErrorBody(ResponseInterface $response): string
|
||||
{
|
||||
try {
|
||||
$body = trim($response->getContent(false));
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
return '<no response body>';
|
||||
}
|
||||
|
||||
if ('' === $body) {
|
||||
return '<empty response body>';
|
||||
}
|
||||
|
||||
if (mb_strlen($body) > self::ERROR_BODY_EXCERPT_LENGTH) {
|
||||
$body = mb_substr($body, 0, self::ERROR_BODY_EXCERPT_LENGTH) . '…';
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* The submission uuid the endpoint returns is what makes a support question ("did our
|
||||
* application arrive?") answerable from the TYPO3 log alone. Reading the body also drains
|
||||
* the response, which returns the connection to the pool instead of cancelling the transfer.
|
||||
* Deliberately without any of the submitted values: these payloads are personal data.
|
||||
*/
|
||||
private function logAcceptedSubmission(ResponseInterface $response, array $payload): void
|
||||
{
|
||||
try {
|
||||
$data = $response->toArray(false);
|
||||
} catch (\Throwable $e) {
|
||||
// A body that is absent (204) or not JSON costs nothing here - the submission is
|
||||
// stored either way, which the asserted status has already established.
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $this->logger) {
|
||||
$this->logger->info('Application form submission accepted by the EP Team API.', [
|
||||
'form' => $payload['form'] ?? null,
|
||||
'pageUid' => $payload['pageUid'] ?? null,
|
||||
'uuid' => $data['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function getHttpClient(): HttpClientInterface
|
||||
{
|
||||
if (null !== static::$httpClient) {
|
||||
return static::$httpClient;
|
||||
}
|
||||
|
||||
$config = $this->getConfiguration();
|
||||
$baseUrl = trim((string)($config['epTeamApiBaseUrl'] ?? ''));
|
||||
$apiKey = trim((string)($config['epTeamApiKey'] ?? ''));
|
||||
|
||||
if ('' === $baseUrl) {
|
||||
throw new ApiException('EP Team API base URL is not configured.');
|
||||
}
|
||||
if ('' === $apiKey) {
|
||||
throw new ApiException('EP Team API key is not configured.');
|
||||
}
|
||||
if (0 !== strpos($baseUrl, 'https://')) {
|
||||
// The application answers an http request with a 301 to the https URL, and this
|
||||
// client does not follow redirects, so a plain http base URL fails every submission.
|
||||
$this->logWarning(sprintf('EP Team API base URL is not https (%s); submissions will fail on the redirect.', $baseUrl));
|
||||
}
|
||||
|
||||
$options = [
|
||||
'headers' => [
|
||||
'X-Api-Key' => $apiKey,
|
||||
],
|
||||
];
|
||||
|
||||
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
|
||||
// so a stalled API pins a PHP worker for a minute per request - and this call happens
|
||||
// inside the visitor's form submission, with the visitor waiting for it. The defaults
|
||||
// live in code rather than in ext_conf_template.txt: ExtensionConfiguration::get()
|
||||
// returns the stored configuration verbatim and only syncs the template when an
|
||||
// extension has no configuration at all, so a newly added template key is absent until
|
||||
// someone saves extension configuration by hand. Falling back to 0 there would silently
|
||||
// mean "no timeout" - the opposite of what this guard is for. An explicit 0 still
|
||||
// disables.
|
||||
$timeout = (int)($config['epTeamApiTimeout'] ?? self::DEFAULT_TIMEOUT);
|
||||
if ($timeout > 0) {
|
||||
$options['timeout'] = $timeout;
|
||||
}
|
||||
$maxDuration = (int)($config['epTeamApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
|
||||
if ($maxDuration > 0) {
|
||||
$options['max_duration'] = $maxDuration;
|
||||
}
|
||||
|
||||
return static::$httpClient = HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
|
||||
}
|
||||
|
||||
private function getConfiguration(): array
|
||||
{
|
||||
if (null === $this->configuration) {
|
||||
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
|
||||
->get('ep_products');
|
||||
}
|
||||
|
||||
return $this->configuration;
|
||||
}
|
||||
|
||||
private function logError(string $message, array $context = []): void
|
||||
{
|
||||
if (null !== $this->logger) {
|
||||
$this->logger->error($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function logWarning(string $message, array $context = []): void
|
||||
{
|
||||
if (null !== $this->logger) {
|
||||
$this->logger->warning($message, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\EpTeam;
|
||||
|
||||
class ApiException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -60,3 +60,15 @@ contentFeedNewsDetailPages = ep-reisen:1392
|
||||
|
||||
# cat=Import; type=int+; label=Storage page of the imported dates and rooms
|
||||
importStoragePid = 11
|
||||
|
||||
# cat=EpTeamAPI; type=string; label=MyE&P Team base URL (https, e.g. https://team.ep-reisen.de) - receives application form submissions
|
||||
epTeamApiBaseUrl =
|
||||
|
||||
# cat=EpTeamAPI; type=string; label=MyE&P Team application form API key (sent as X-Api-Key)
|
||||
epTeamApiKey =
|
||||
|
||||
# cat=EpTeamAPI; type=int; label=MyE&P Team API inactivity timeout in seconds (0 = PHP default_socket_timeout)
|
||||
epTeamApiTimeout = 5
|
||||
|
||||
# cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited)
|
||||
epTeamApiMaxDuration = 15
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use TYPO3\CMS\Core\Log\LogManager;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
|
||||
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
|
||||
|
||||
abstract class AbstractMappedFinisher extends AbstractFinisher
|
||||
{
|
||||
/**
|
||||
* Resolves the 'mapping' option into remote name => local form element identifier.
|
||||
*/
|
||||
protected function parseMapping(): array
|
||||
{
|
||||
$mappings = [];
|
||||
$mappingOption = (string)$this->parseOption('mapping');
|
||||
|
||||
foreach (GeneralUtility::trimExplode(',', $mappingOption, true) as $mapping) {
|
||||
$parts = GeneralUtility::trimExplode(':', $mapping, true, 2);
|
||||
if (2 !== count($parts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$remote, $local] = $parts;
|
||||
if ('' === $remote || '' === $local) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mappings[$remote] = $local;
|
||||
}
|
||||
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped values, keyed by the remote name the receiving side expects. Values pass through
|
||||
* normalizeValue() just like the labeled ones do, so a date element arrives as a Y-m-d string
|
||||
* instead of a serialized DateTime. A value that does not survive normalization keeps its key:
|
||||
* the remote names make up the request shape and dropping one would change it.
|
||||
*/
|
||||
protected function buildPayload(): array
|
||||
{
|
||||
$values = [];
|
||||
$formValues = $this->finisherContext->getFormValues();
|
||||
|
||||
foreach ($this->parseMapping() as $remote => $local) {
|
||||
if (false === array_key_exists($local, $formValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$remote] = $this->normalizeValue($formValues[$local], $local);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every submitted value that the mapping does not already cover, keyed by the element label
|
||||
* as the editor sees it in the form editor. Forms built in the backend use generated
|
||||
* identifiers (text-1, singleselect-2, ...) that carry no meaning and cannot be renamed
|
||||
* without breaking existing finisher options, so the label is the only stable description
|
||||
* of what was asked.
|
||||
*
|
||||
* The label is taken from the form definition, not through TranslationService: a translated
|
||||
* label would change the payload keys with the site language, and these keys are stored.
|
||||
*/
|
||||
protected function buildLabeledValues(): array
|
||||
{
|
||||
$formValues = $this->finisherContext->getFormValues();
|
||||
$skip = array_merge(
|
||||
array_values($this->parseMapping()),
|
||||
GeneralUtility::trimExplode(',', (string)$this->parseOption('exclude'), true)
|
||||
);
|
||||
|
||||
// Keyed by identifier in form order, and contains only real form elements - iterating
|
||||
// this instead of the form values keeps the collection in the order of the form and
|
||||
// leaves out anything that is not an element the visitor filled in.
|
||||
$elements = $this->finisherContext->getFormRuntime()->getFormDefinition()->getElements();
|
||||
|
||||
$collected = [];
|
||||
foreach ($elements as $identifier => $element) {
|
||||
if (true === in_array($identifier, $skip, true) || false === array_key_exists($identifier, $formValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->normalizeValue($formValues[$identifier], $identifier);
|
||||
if (null === $value || '' === $value || [] === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim($element->getLabel());
|
||||
if ('' === $label) {
|
||||
$label = $identifier;
|
||||
}
|
||||
if (true === array_key_exists($label, $collected)) {
|
||||
$label .= ' (' . $identifier . ')';
|
||||
}
|
||||
|
||||
$collected[$label] = $value;
|
||||
}
|
||||
|
||||
return $collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return mixed null when the value has no JSON representation
|
||||
*/
|
||||
protected function normalizeValue($value, string $identifier)
|
||||
{
|
||||
if (true === is_array($value)) {
|
||||
$normalized = [];
|
||||
foreach ($value as $item) {
|
||||
$item = $this->normalizeValue($item, $identifier);
|
||||
if (null !== $item && '' !== $item) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
if ($value instanceof FileReference) {
|
||||
$resource = $value->getOriginalResource();
|
||||
|
||||
return null !== $resource ? $resource->getName() : null;
|
||||
}
|
||||
|
||||
if (true === is_object($value)) {
|
||||
$this->logWarning('Form value skipped while collecting labeled values: unsupported type.', [
|
||||
'element' => $identifier,
|
||||
'class' => get_class($value),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function logWarning(string $message, array $context = []): void
|
||||
{
|
||||
GeneralUtility::makeInstance(LogManager::class)
|
||||
->getLogger(static::class)
|
||||
->warning($message, array_merge([
|
||||
'finisher' => static::class,
|
||||
], $context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpTheme\Middleware\ReferrerMiddleware;
|
||||
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
|
||||
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
|
||||
|
||||
/**
|
||||
* Adds the URL of the form's page and, if known, the page the visitor came from (see
|
||||
* ReferrerMiddleware) to the submission, so the e-mail finishers include them.
|
||||
*/
|
||||
class AddReferrerFinisher extends AbstractFinisher
|
||||
{
|
||||
protected function executeInternal()
|
||||
{
|
||||
$this->addCurrentUrlField();
|
||||
$this->addReferrerField();
|
||||
}
|
||||
|
||||
private function addReferrerField(): void
|
||||
{
|
||||
$frontendUser = $this->finisherContext->getRequest()->getAttribute('frontend.user');
|
||||
if (!$frontendUser instanceof FrontendUserAuthentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
$referrer = $frontendUser->getKey('ses', ReferrerMiddleware::SESSION_KEY);
|
||||
if (null === $referrer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addField('referrer', 'Referrer', $referrer);
|
||||
|
||||
$frontendUser->setKey('ses', ReferrerMiddleware::SESSION_KEY, null);
|
||||
$frontendUser->storeSessionData();
|
||||
}
|
||||
|
||||
private function addCurrentUrlField(): void
|
||||
{
|
||||
$request = $this->finisherContext->getRequest();
|
||||
$routing = $request->getAttribute('routing');
|
||||
$site = $request->getAttribute('site');
|
||||
if (!$routing instanceof PageArguments || !$site instanceof Site) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page itself, not the form page type the submission was posted to
|
||||
$url = (string)$site->getRouter()->generateUri($routing->getPageId(), [
|
||||
'_language' => $request->getAttribute('language'),
|
||||
]);
|
||||
$this->addField('pageurl', 'URL', $url);
|
||||
}
|
||||
|
||||
private function addField(string $identifier, string $label, string $value): void
|
||||
{
|
||||
$field = $this->finisherContext
|
||||
->getFormRuntime()
|
||||
->getFormDefinition()
|
||||
->getPageByIndex(0)
|
||||
->createElement($identifier, 'Text');
|
||||
|
||||
$field->setDefaultValue($value);
|
||||
$field->setDataType('string');
|
||||
$field->setLabel($label);
|
||||
|
||||
// Finisher variable for potential later use
|
||||
$this->finisherContext->getFinisherVariableProvider()->add(
|
||||
$this->shortFinisherIdentifier,
|
||||
$identifier,
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpProducts\EpTeam\ApiClient;
|
||||
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
|
||||
/**
|
||||
* Sends job applications to MyE&P Team (POST /api/application-forms).
|
||||
*/
|
||||
class ApplicationApiFinisher extends AbstractMappedFinisher
|
||||
{
|
||||
public function __construct(private readonly ApiClient $apiClient) {}
|
||||
|
||||
protected function executeInternal()
|
||||
{
|
||||
try {
|
||||
$payload = $this->buildPayload();
|
||||
if ([] === $payload) {
|
||||
$this->logWarning('Application form API finisher skipped: the mapping produced no values.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$formDefinition = $this->finisherContext->getFormRuntime()->getFormDefinition();
|
||||
$formLabel = trim($formDefinition->getLabel());
|
||||
|
||||
$payload['form'] = $formDefinition->getIdentifier();
|
||||
// The name the editor gave the form, so the receiving side can label a submission
|
||||
// without keeping a list of identifiers. Taken from the definition rather than
|
||||
// through TranslationService: a translated label would differ per site language.
|
||||
$payload['formLabel'] = '' !== $formLabel ? $formLabel : $formDefinition->getIdentifier();
|
||||
$payload['submittedAt'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
|
||||
$payload = array_merge($payload, $this->resolvePageContext());
|
||||
$payload['details'] = $this->buildLabeledValues();
|
||||
|
||||
$this->apiClient->submitApplicationForm($payload);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logWarning('Application form API finisher request failed.', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePageContext(): array
|
||||
{
|
||||
$request = $this->finisherContext->getRequest();
|
||||
$routing = $request->getAttribute('routing');
|
||||
if (!$routing instanceof PageArguments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pageUid = $routing->getPageId();
|
||||
$context = ['pageUid' => $pageUid];
|
||||
|
||||
try {
|
||||
/** @var Site $site */
|
||||
$site = $request->getAttribute('site');
|
||||
// The page itself, not the form page type the submission was posted to
|
||||
$context['pageUrl'] = (string)$site->getRouter()->generateUri($pageUid, [
|
||||
'_language' => $request->getAttribute('language'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// An unroutable page must not cost the submission, so the uid is sent without a URL.
|
||||
$this->logWarning('Application form API finisher could not resolve the page URL.', [
|
||||
'pageUid' => $pageUid,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpProducts\MyEP\ApiClient;
|
||||
|
||||
/**
|
||||
* Registers the sender's address in BusPro through MyE&P (POST contactform). A failing API never
|
||||
* costs the visitor the submission: the e-mail finisher still runs.
|
||||
*/
|
||||
class BpnApiFinisher extends AbstractMappedFinisher
|
||||
{
|
||||
public function __construct(private readonly ApiClient $apiClient) {}
|
||||
|
||||
protected function executeInternal()
|
||||
{
|
||||
try {
|
||||
$values = $this->buildPayload();
|
||||
if ([] !== $values) {
|
||||
$this->apiClient->registerAddress($values);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logWarning('Bpn API finisher request failed.', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* htmx follows a redirect inside its XHR and would swap the target page into the form. A redirect
|
||||
* answering an htmx request (e.g. the Redirect finisher of a form) becomes HX-Redirect instead,
|
||||
* which sends the browser itself to the new location.
|
||||
*/
|
||||
class HtmxRedirectMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private readonly ResponseFactoryInterface $responseFactory) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
|
||||
if ($request->getHeaderLine('HX-Request') !== 'true'
|
||||
|| $response->getStatusCode() < 300
|
||||
|| $response->getStatusCode() >= 400
|
||||
|| !$response->hasHeader('Location')
|
||||
) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $this->responseFactory->createResponse(200)
|
||||
->withHeader('HX-Redirect', $response->getHeaderLine('Location'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
|
||||
|
||||
/**
|
||||
* Links to a form page carry the uid of the page they come from (?ref=123, e.g. the enquiry
|
||||
* button of a product page). Its URL is kept in the session until AddReferrerFinisher puts it
|
||||
* into the submission. On static pages the parameter reaches TYPO3 with the request that loads
|
||||
* the form (see the form placeholder of the static export).
|
||||
*/
|
||||
class ReferrerMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public const SESSION_KEY = 'referrer';
|
||||
|
||||
public function __construct(private readonly SiteFinder $siteFinder) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$ref = $request->getQueryParams()['ref'] ?? null;
|
||||
$frontendUser = $request->getAttribute('frontend.user');
|
||||
|
||||
if (!is_numeric($ref) || !$frontendUser instanceof FrontendUserAuthentication) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$url = (string)$this->siteFinder->getSiteByPageId((int)$ref)->getRouter()->generateUri((int)$ref);
|
||||
$frontendUser->setKey('ses', self::SESSION_KEY, $url);
|
||||
$frontendUser->storeSessionData();
|
||||
} catch (SiteNotFoundException) {
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,25 @@ return [
|
||||
'typo3/cms-frontend/page-resolver',
|
||||
],
|
||||
],
|
||||
'ep/referrer' => [
|
||||
'target' => \EP\EpTheme\Middleware\ReferrerMiddleware::class,
|
||||
'after' => [
|
||||
'typo3/cms-frontend/authentication',
|
||||
],
|
||||
'before' => [
|
||||
'typo3/cms-frontend/page-resolver',
|
||||
],
|
||||
],
|
||||
// Outermost of the frontend stack after the site is known, so it also sees redirects
|
||||
// that core middlewares or exceptions (PropagateResponseException) produce
|
||||
'ep/htmx-redirect' => [
|
||||
'target' => \EP\EpTheme\Middleware\HtmxRedirectMiddleware::class,
|
||||
'after' => [
|
||||
'typo3/cms-frontend/site',
|
||||
],
|
||||
'before' => [
|
||||
'typo3/cms-frontend/authentication',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -7,3 +7,9 @@ services:
|
||||
EP\EpTheme\:
|
||||
resource: '../Classes/*'
|
||||
exclude: '../Classes/Domain/Model/*'
|
||||
|
||||
# Form finishers are created with GeneralUtility::makeInstance(), once per form submission
|
||||
EP\EpTheme\Form\:
|
||||
resource: '../Classes/Form/*'
|
||||
public: true
|
||||
shared: false
|
||||
@@ -0,0 +1,20 @@
|
||||
@import 'EXT:form/Configuration/TypoScript/setup.typoscript'
|
||||
@import 'EXT:form_element_linked_checkbox/Configuration/TypoScript/setup.typoscript'
|
||||
@import 'EXT:bw_captcha/Configuration/TypoScript/setup.typoscript'
|
||||
|
||||
plugin.tx_form.view {
|
||||
templateRootPaths.10 = EXT:ep_theme/Resources/Private/Templates/Form/
|
||||
}
|
||||
|
||||
plugin.tx_form.settings.yamlConfigurations {
|
||||
1630994710 = EXT:ep_theme/Configuration/Yaml/FormSetup.yaml
|
||||
}
|
||||
|
||||
plugin.tx_bwcaptcha.settings {
|
||||
charset = abcdefghjkmnpqrtuvwxy34679
|
||||
length = 4
|
||||
width = 220
|
||||
height = 60
|
||||
# The partial has its own reload button and no audio
|
||||
audioButton = 0
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# The forms of a page without the page around them (/path/form). The static pages of the hybrid
|
||||
# setup load this into a placeholder with htmx, and the forms post back to it (see
|
||||
# FormSetup.yaml): the page URL itself is a static file that never reaches TYPO3.
|
||||
# Every form page has a single form, wherever it sits (a grid or container counts as well).
|
||||
|
||||
tx_eptheme_form = PAGE
|
||||
tx_eptheme_form {
|
||||
typeNum = 1704
|
||||
config {
|
||||
disableAllHeaderCode = 1
|
||||
disablePrefixComment = 1
|
||||
additionalHeaders.10.header = Content-Type: text/html; charset=utf-8
|
||||
additionalHeaders.20.header = X-Robots-Tag: noindex, nofollow
|
||||
admPanel = 0
|
||||
debug = 0
|
||||
}
|
||||
|
||||
10 = FLUIDTEMPLATE
|
||||
10 {
|
||||
templateName = Island
|
||||
templateRootPaths.0 = EXT:ep_theme/Resources/Private/Templates/Form/
|
||||
partialRootPaths.0 = EXT:ep_theme/Resources/Private/Partials/
|
||||
|
||||
variables.forms = CONTENT
|
||||
variables.forms {
|
||||
table = tt_content
|
||||
select {
|
||||
where = {#CType} = 'form_formframework'
|
||||
orderBy = sorting
|
||||
}
|
||||
# Only the plugin (Templates/Form/Render.html): header and frame stay on the static page
|
||||
renderObj =< tt_content.form_formframework.20
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
TYPO3:
|
||||
CMS:
|
||||
Form:
|
||||
persistenceManager:
|
||||
allowedExtensionPaths:
|
||||
10: EXT:ep_theme/Resources/Private/Forms/
|
||||
|
||||
prototypes:
|
||||
standard:
|
||||
formElementsDefinition:
|
||||
Form:
|
||||
renderingOptions:
|
||||
templateRootPaths:
|
||||
1505042810: 'EXT:ep_theme/Resources/Private/Templates/Form/'
|
||||
partialRootPaths:
|
||||
1623227657: 'EXT:ep_theme/Resources/Private/Partials/Form/'
|
||||
layoutRootPaths:
|
||||
1505042810: 'EXT:ep_theme/Resources/Private/Layouts/Form/'
|
||||
# Every form has a captcha
|
||||
honeypot:
|
||||
enable: false
|
||||
# Forms post to the form page type (tx_eptheme_form, /form): on the static
|
||||
# pages of the hybrid setup the page URL itself never reaches TYPO3
|
||||
additionalParams:
|
||||
type: 1704
|
||||
|
||||
finishersDefinition:
|
||||
BpnApiFinisher:
|
||||
implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher'
|
||||
formEditor:
|
||||
label: 'BusPro Finisher'
|
||||
options:
|
||||
identifier: 'BpnApiFinisher'
|
||||
mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1'
|
||||
ApplicationApiFinisher:
|
||||
implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher'
|
||||
formEditor:
|
||||
label: 'Application Finisher'
|
||||
options:
|
||||
identifier: 'ApplicationApiFinisher'
|
||||
mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1'
|
||||
exclude: 'captcha,privacypolicyaccepted'
|
||||
AddReferrerFinisher:
|
||||
implementationClassName: 'EP\EpTheme\Form\AddReferrerFinisher'
|
||||
formEditor:
|
||||
label: 'Referrer Finisher'
|
||||
options:
|
||||
identifier: 'AddReferrerFinisher'
|
||||
|
||||
contactform:
|
||||
__inheritances:
|
||||
10: 'TYPO3.CMS.Form.prototypes.standard'
|
||||
|
||||
applicationform:
|
||||
__inheritances:
|
||||
10: 'TYPO3.CMS.Form.prototypes.standard'
|
||||
@@ -0,0 +1,378 @@
|
||||
|
||||
renderingOptions:
|
||||
submitButtonLabel: Absenden
|
||||
type: Form
|
||||
identifier: applicationform
|
||||
label: Bewerbungsformular
|
||||
prototypeName: applicationform
|
||||
finishers:
|
||||
-
|
||||
options:
|
||||
subject: 'Bewerbung von {text-1}'
|
||||
recipientAddress: [email protected]
|
||||
recipientName: 'E&P Reisen'
|
||||
senderAddress: [email protected]
|
||||
senderName: '{text-1}'
|
||||
replyToAddress: '{text-6}'
|
||||
carbonCopyAddress: ''
|
||||
blindCarbonCopyAddress: ''
|
||||
format: html
|
||||
attachUploads: false
|
||||
translation:
|
||||
language: ''
|
||||
identifier: EmailToReceiver
|
||||
-
|
||||
options:
|
||||
message: 'Vielen Dank für deine Bewerbung. Wir werden sie so schnell wie möglich bearbeiten.'
|
||||
identifier: Confirmation
|
||||
renderables:
|
||||
-
|
||||
renderingOptions:
|
||||
previousButtonLabel: zurück
|
||||
nextButtonLabel: weiter
|
||||
type: Page
|
||||
identifier: page-1
|
||||
label: ''
|
||||
renderables:
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-1
|
||||
label: Name
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Vor- & Nachname'
|
||||
required: required
|
||||
elementDescription: ''
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-2
|
||||
label: Straße
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Straße & Hausnummer'
|
||||
required: required
|
||||
elementDescription: ''
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-3
|
||||
label: Wohnort
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'PLZ & Ort'
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-4
|
||||
label: 'Mobil Nummer'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-5
|
||||
label: 'Festnetz Nummer'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-6
|
||||
label: 'E-Mail Adresse'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
identifier: EmailAddress
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-7
|
||||
label: Geburtsdatum
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: tt.mm.jjjj
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
options:
|
||||
regularExpression: '/\d{2}\.\d{2}\.\d{4}/'
|
||||
identifier: RegularExpression
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-8
|
||||
label: Nationalität
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
nein: nein
|
||||
ja: ja
|
||||
'ja, mit Anhänger': 'ja, mit Anhänger'
|
||||
type: RadioButton
|
||||
identifier: radiobutton-1
|
||||
label: Führerschein
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-9
|
||||
label: Ausbildung/Studium
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Schul-, Uni-, Berufsausbildung'
|
||||
elementDescription: ''
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-10
|
||||
label: 'Aktuelle Tätigkeit'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
Haus-/Destinationsleitung: Haus-/Destinationsleitung
|
||||
Koch/Köchin: Koch/Köchin
|
||||
Koch-Assistenz/Service: Koch-Assistenz/Service
|
||||
'Serviceleitung/Assistenz Hausleitung': 'Serviceleitung/Assistenz Hausleitung'
|
||||
Serviceteamer: Serviceteamer
|
||||
Reinigungskraft: Reinigungskraft
|
||||
Materialteamer/Verleih: Materialteamer/Verleih
|
||||
Allrounder: Allrounder
|
||||
'Praktikum im Schnee': 'Praktikum im Schnee'
|
||||
elementDescription: ''
|
||||
type: MultiCheckbox
|
||||
identifier: multicheckbox-1
|
||||
label: 'Bewerbung als'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-11
|
||||
label: 'Einsatz datum'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'von tt.mm.jjjj bis tt.mm.jjjj'
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
Ski: Ski
|
||||
Snowboard: Snowboard
|
||||
nichts: nichts
|
||||
type: MultiCheckbox
|
||||
identifier: multicheckbox-2
|
||||
label: 'Ich fahre'
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
options:
|
||||
minimum: '1'
|
||||
maximum: '2'
|
||||
identifier: Count
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
'noch nicht gelernt': 'noch nicht gelernt'
|
||||
Anfänger: Anfänger
|
||||
Fortgeschritten: Fortgeschritten
|
||||
Experte: Experte
|
||||
type: SingleSelect
|
||||
identifier: singleselect-1
|
||||
label: 'Wie gut fahre ich Ski'
|
||||
defaultValue: 'noch nicht gelernt'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-12
|
||||
label: 'Skifahrer seit'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
'noch nicht gelernt': 'noch nicht gelernt'
|
||||
Anfänger: Anfänger
|
||||
Fortgeschritten: Fortgeschritten
|
||||
Experte: Experte
|
||||
type: SingleSelect
|
||||
identifier: singleselect-2
|
||||
label: 'Wie gut fahre ich Snowboard'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-13
|
||||
label: 'Snowboarder seit'
|
||||
properties:
|
||||
elementDescription: ''
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
nein: nein
|
||||
'Ski Grundstufe': 'Ski Grundstufe'
|
||||
'Board Grundstufe': 'Board Grundstufe'
|
||||
'Ski Instructor': 'Ski Instructor'
|
||||
'Board Instructor': 'Board Instructor'
|
||||
'Ski Begleitschein': 'Ski Begleitschein'
|
||||
'Board Begleitschein': 'Board Begleitschein'
|
||||
type: MultiSelect
|
||||
identifier: multiselect-1
|
||||
label: Ski-/Snowboardlehrerschein
|
||||
defaultValue:
|
||||
- nein
|
||||
-
|
||||
type: Fieldset
|
||||
identifier: fieldset-1
|
||||
label: Fremdsprachen
|
||||
renderables:
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
nein: nein
|
||||
Grundkenntnisse: Grundkenntnisse
|
||||
gut: gut
|
||||
'sehr gut': 'sehr gut'
|
||||
type: SingleSelect
|
||||
identifier: singleselect-3
|
||||
label: Englisch
|
||||
defaultValue: nein
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
nein: nein
|
||||
Grundkenntnisse: Grundkenntnisse
|
||||
gut: gut
|
||||
'sehr gut': 'sehr gut'
|
||||
type: SingleSelect
|
||||
identifier: singleselect-4
|
||||
label: Französisch
|
||||
defaultValue: nein
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
nein: nein
|
||||
Grundkenntnisse: Grundkenntnisse
|
||||
gut: gut
|
||||
'sehr gut': 'sehr gut'
|
||||
type: SingleSelect
|
||||
identifier: singleselect-5
|
||||
label: Spanisch
|
||||
defaultValue: nein
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-14
|
||||
label: 'Sonstige Fremdsprachen'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
ja: ja
|
||||
nein: nein
|
||||
type: RadioButton
|
||||
identifier: radiobutton-2
|
||||
label: 'Kocherfahrung für Gruppen (min. 30 Personen)'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-15
|
||||
label: 'Wenn ja, welche?'
|
||||
properties:
|
||||
elementDescription: ''
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
ja: ja
|
||||
nein: nein
|
||||
type: RadioButton
|
||||
identifier: radiobutton-3
|
||||
label: 'Erfahrungen im Tourismus'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: text-16
|
||||
label: 'Wenn ja, welche?'
|
||||
properties:
|
||||
elementDescription: ''
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
'ja, mit Zertifikat zur Bindungseinstellung': 'ja, mit Zertifikat zur Bindungseinstellung'
|
||||
nein: nein
|
||||
type: RadioButton
|
||||
identifier: radiobutton-4
|
||||
label: 'Erfahrungen im Skiverleih'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
Zillertal: Zillertal
|
||||
Lenzerheide: Lenzerheide
|
||||
'Davos Klosters': 'Davos Klosters'
|
||||
Montafon: Montafon
|
||||
Saalbach: Saalbach
|
||||
'Les Deux Alpes': 'Les Deux Alpes'
|
||||
'4 Vallées': '4 Vallées'
|
||||
'Val Thorens': 'Val Thorens'
|
||||
'Les Sybelles': 'Les Sybelles'
|
||||
'Portes du Soleil': 'Portes du Soleil'
|
||||
type: MultiCheckbox
|
||||
identifier: multicheckbox-3
|
||||
label: 'Ortskenntnisse in folgenden Destinationen'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
'Damen 36': 'Damen 36'
|
||||
'Damen 38': 'Damen 38'
|
||||
'Damen 40': 'Damen 40'
|
||||
'Damen 42': 'Damen 42'
|
||||
'Herren S': 'Herren S'
|
||||
'Herren M': 'Herren M'
|
||||
'Herren L': 'Herren L'
|
||||
'Herren XL': 'Herren XL'
|
||||
type: SingleSelect
|
||||
identifier: singleselect-6
|
||||
label: Jackengröße
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Textarea
|
||||
identifier: textarea-1
|
||||
label: 'Warum möchte ich für E&P Reisen arbeiten?'
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
Freunde: Freunde
|
||||
'Social Media': 'Social Media'
|
||||
Uni: Uni
|
||||
Sonstiges: Sonstiges
|
||||
type: MultiCheckbox
|
||||
identifier: multicheckbox-4
|
||||
label: 'Wie bin ich auf E&P Reisen aufmerksam geworden?'
|
||||
-
|
||||
type: LinkedCheckbox
|
||||
identifier: privacypolicyaccepted
|
||||
label: 'Ich akzeptiere die %s.'
|
||||
properties:
|
||||
pageUid: '355'
|
||||
linkText: 'Datenschutzbestimmungen'
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
@@ -0,0 +1,148 @@
|
||||
|
||||
renderingOptions:
|
||||
submitButtonLabel: Absenden
|
||||
type: Form
|
||||
identifier: contactform
|
||||
label: Kontaktformular
|
||||
prototypeName: contactform
|
||||
finishers:
|
||||
-
|
||||
identifier: BpnApiFinisher
|
||||
options:
|
||||
mapping: 'lastName:lastName,firstName:firstName,gender:gender,email:email,street:street,zipCode:zipCode,city:city,phone:phone'
|
||||
-
|
||||
options:
|
||||
subject: 'Kontaktanfrage von {firstName} {lastName}'
|
||||
recipientAddress: [email protected]
|
||||
recipientName: 'E&P Reisen'
|
||||
senderAddress: [email protected]
|
||||
senderName: '{firstName} {lastName}'
|
||||
replyToAddress: '{email}'
|
||||
carbonCopyAddress: ''
|
||||
blindCarbonCopyAddress: ''
|
||||
format: html
|
||||
attachUploads: false
|
||||
translation:
|
||||
language: ''
|
||||
identifier: EmailToReceiver
|
||||
-
|
||||
options:
|
||||
pageUid: '1387'
|
||||
additionalParameters: ''
|
||||
identifier: Redirect
|
||||
renderables:
|
||||
-
|
||||
renderingOptions:
|
||||
previousButtonLabel: 'Previous step'
|
||||
nextButtonLabel: 'Neue Seite'
|
||||
type: Page
|
||||
identifier: page-1
|
||||
label: ''
|
||||
renderables:
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: firstName
|
||||
label: Vorname
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Vorname'
|
||||
required: required
|
||||
elementDescription: ''
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: lastName
|
||||
label: 'Nachname'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Nachname'
|
||||
required: required
|
||||
elementDescription: ''
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
properties:
|
||||
options:
|
||||
M: M
|
||||
W: W
|
||||
D: D
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
type: SingleSelect
|
||||
identifier: gender
|
||||
label: 'Gender'
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: email
|
||||
label: 'E-Mail'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
identifier: EmailAddress
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: street
|
||||
label: 'Straße'
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
placeholder: 'Straße & Nr.'
|
||||
elementDescription: ''
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: zipCode
|
||||
label: 'PLZ'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: city
|
||||
label: 'Ort'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: phone
|
||||
label: 'Telefon'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Textarea
|
||||
identifier: message
|
||||
label: 'Deine Kontaktanfrage'
|
||||
-
|
||||
defaultValue: ''
|
||||
type: Text
|
||||
identifier: bookingNumber
|
||||
label: 'Buchungsnummer (falls vorhanden)'
|
||||
-
|
||||
type: LinkedCheckbox
|
||||
identifier: privacypolicyaccepted
|
||||
label: 'Ich akzeptiere die %s.'
|
||||
properties:
|
||||
pageUid: '355'
|
||||
linkText: 'Datenschutzbestimmungen'
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
validators:
|
||||
-
|
||||
identifier: NotEmpty
|
||||
-
|
||||
type: Captcha
|
||||
identifier: captcha
|
||||
label: Captcha
|
||||
properties:
|
||||
fluidAdditionalAttributes:
|
||||
required: required
|
||||
autocomplete: 'off'
|
||||
@@ -0,0 +1,388 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true" >
|
||||
<head>
|
||||
<!--[if gte mso 15]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title></title>
|
||||
<style type="text/css">
|
||||
p {
|
||||
margin:10px 0;
|
||||
padding:0;
|
||||
}
|
||||
table {
|
||||
border-collapse:collapse;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
display:block;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
img,
|
||||
a img {
|
||||
border:0;
|
||||
height:auto;
|
||||
outline:none;
|
||||
text-decoration:none;
|
||||
}
|
||||
body,
|
||||
#bodyTable,
|
||||
#bodyCell {
|
||||
height:100%;
|
||||
margin:0;
|
||||
padding:0;
|
||||
width:100%;
|
||||
}
|
||||
#outlook a {
|
||||
padding:0;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode:bicubic;
|
||||
}
|
||||
table {
|
||||
mso-table-lspace:0pt;
|
||||
mso-table-rspace:0pt;
|
||||
}
|
||||
.ReadMsgBody {
|
||||
width:100%;
|
||||
}
|
||||
.ExternalClass {
|
||||
width:100%;
|
||||
}
|
||||
p,
|
||||
a,
|
||||
td {
|
||||
mso-line-height-rule:exactly;
|
||||
}
|
||||
a[href^=tel],
|
||||
a[href^=sms] {
|
||||
color:inherit;
|
||||
cursor:default;
|
||||
text-decoration:none;
|
||||
}
|
||||
p,
|
||||
a,
|
||||
td,
|
||||
body,
|
||||
table,
|
||||
blockquote {
|
||||
-ms-text-size-adjust:100%;
|
||||
-webkit-text-size-adjust:100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font {
|
||||
line-height:100%;
|
||||
}
|
||||
a[x-apple-data-detectors]{
|
||||
color:inherit !important;
|
||||
text-decoration:none !important;
|
||||
font-size:inherit !important;
|
||||
font-family:inherit !important;
|
||||
font-weight:inherit !important;
|
||||
line-height:inherit !important;
|
||||
}
|
||||
|
||||
.templateContainer {
|
||||
max-width:600px !important;
|
||||
}
|
||||
a.button {
|
||||
display:block;
|
||||
}
|
||||
.image,.retinaImage {
|
||||
vertical-align:bottom;
|
||||
}
|
||||
.textContent {
|
||||
word-break:break-word;
|
||||
}
|
||||
.textContent img {
|
||||
height:auto !important;
|
||||
}
|
||||
.dividerBlock {
|
||||
table-layout:fixed !important;
|
||||
}
|
||||
h1 {
|
||||
color:#222222;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:40px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:150%;
|
||||
letter-spacing:normal;
|
||||
text-align:center;
|
||||
}
|
||||
h2 {
|
||||
color:#222222;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:34px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:150%;
|
||||
letter-spacing:normal;
|
||||
text-align:left;
|
||||
}
|
||||
h3 {
|
||||
color:#444444;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:22px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:150%;
|
||||
letter-spacing:normal;
|
||||
text-align:left;
|
||||
}
|
||||
h4 {
|
||||
color:#999999;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:20px;
|
||||
font-style:italic;
|
||||
font-weight:normal;
|
||||
line-height:125%;
|
||||
letter-spacing:normal;
|
||||
text-align:left;
|
||||
}
|
||||
#templateHeader {
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:0;
|
||||
border-bottom:0;
|
||||
padding-top:36px;
|
||||
padding-bottom:0;
|
||||
}
|
||||
.headerContainer {
|
||||
background-color:#ffffff;
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:1px none ;
|
||||
border-bottom:0;
|
||||
padding-top:36px;
|
||||
padding-bottom:36px;
|
||||
}
|
||||
.headerContainer .textContent,
|
||||
.headerContainer .textContent p {
|
||||
color:#808080;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:16px;
|
||||
line-height:150%;
|
||||
text-align:left;
|
||||
}
|
||||
.headerContainer .textContent a,
|
||||
.headerContainer .textContent p a {
|
||||
color:#538bb3;
|
||||
font-weight:normal;
|
||||
text-decoration:underline;
|
||||
}
|
||||
#templateBody {
|
||||
background-color:#f2f2f2;
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:0;
|
||||
border-bottom:0;
|
||||
padding-top:0;
|
||||
padding-bottom:0;
|
||||
}
|
||||
.bodyContainer {
|
||||
background-color:#ffffff;
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:0;
|
||||
border-bottom:0;
|
||||
padding-top:0;
|
||||
padding-bottom:18px;
|
||||
}
|
||||
.bodyContainer .textContent,
|
||||
.bodyContainer .textContent p {
|
||||
color:#808080;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:16px;
|
||||
line-height:150%;
|
||||
text-align:left;
|
||||
}
|
||||
.bodyContainer .textContent a,
|
||||
.bodyContainer .textContent p a {
|
||||
color:#538bb3;
|
||||
font-weight:normal;
|
||||
text-decoration:underline;
|
||||
}
|
||||
#templateFooter{
|
||||
background-color:#f2f2f2;
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:1px none ;
|
||||
border-bottom:0;
|
||||
padding-top:0;
|
||||
padding-bottom:36px;
|
||||
}
|
||||
.footerContainer{
|
||||
background-color:#313131;
|
||||
background-image:none;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
border-top:0;
|
||||
border-bottom:0;
|
||||
padding-top:45px;
|
||||
padding-bottom:45px;
|
||||
}
|
||||
.footerContainer .textContent,
|
||||
.footerContainer .textContent p {
|
||||
color:#FFFFFF;
|
||||
font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;
|
||||
font-size:12px;
|
||||
line-height:150%;
|
||||
text-align:center;
|
||||
}
|
||||
.footerContainer .textContent a,
|
||||
.footerContainer .textContent p a {
|
||||
color:#FFFFFF;
|
||||
font-weight:normal;
|
||||
text-decoration:underline;
|
||||
}
|
||||
table.data th,
|
||||
table.data td {
|
||||
vertical-align: top;
|
||||
}
|
||||
@media only screen and (min-width:768px){
|
||||
.templateContainer {
|
||||
width:600px !important;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 480px){
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a {
|
||||
-webkit-text-size-adjust:none !important;
|
||||
}
|
||||
body {
|
||||
width:100% !important;
|
||||
min-width:100% !important;
|
||||
}
|
||||
h1 {
|
||||
font-size:30px !important;
|
||||
line-height:125% !important;
|
||||
}
|
||||
h2 {
|
||||
font-size:26px !important;
|
||||
line-height:125% !important;
|
||||
}
|
||||
h3 {
|
||||
font-size:20px !important;
|
||||
line-height:150% !important;
|
||||
}
|
||||
h4 {
|
||||
font-size:18px !important;
|
||||
line-height:150% !important;
|
||||
}
|
||||
.headerContainer .textContent,
|
||||
.headerContainer .textContent p {
|
||||
font-size:16px !important;
|
||||
line-height:150% !important;
|
||||
}
|
||||
.bodyContainer .textContent,
|
||||
.bodyContainer .textContent p {
|
||||
font-size:16px !important;
|
||||
line-height:150% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
<center>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable"><tr><td align="center" valign="top" id="bodyCell">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
|
||||
<!-- HEADER START -->
|
||||
<tr><td align="center" valign="top" id="templateHeader" style="background-color: #ffffff">
|
||||
|
||||
<!--[if (gte mso 9)|(IE)]><table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;"><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" class="templateContainer"><tr><td valign="top" class="headerContainer">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" class="textBlock" style="min-width:100%;"><tbody class="textBlockOuter"><tr><td valign="top" class="textBlockInner" style="padding-top:9px;">
|
||||
|
||||
<!--[if mso]><table align="left" border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;"><tr><td valign="top" width="600" style="width:600px;"><![endif]-->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%; min-width:100%;" width="100%" class="textContentContainer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" class="image" align="center" style="padding: 0px 36px; padding-bottom: 20px; color: #222222;text-align: center;">
|
||||
<f:image src="EXT:ep_theme/Resources/Public/images/logo_wide.svg" absolute="1" alt="Logo" height="60" width="197" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" class="textContent" style="padding: 0px 36px;color: #222222;font-family: "Helvetica Neue", Helvetica, Arial, Verdana, sans-serif;font-size: 24px;text-align: center;">
|
||||
<strong><f:render section="Title" optional="true" /></strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></table>
|
||||
<!--[if (gte mso 9)|(IE)]></td></tr></table><![endif]-->
|
||||
|
||||
</td></tr>
|
||||
<!-- HEADER END -->
|
||||
|
||||
|
||||
<!-- BODY START -->
|
||||
<tr><td align="center" valign="top" id="templateBody">
|
||||
|
||||
<!--[if (gte mso 9)|(IE)]><table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;"><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" class="templateContainer"><tr><td valign="top" class="bodyContainer">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" class="textBlock" style="min-width:100%;"><tbody class="textBlockOuter"><tr><td valign="top" class="textBlockInner" style="padding: 36px 0px;">
|
||||
|
||||
<!--[if mso]><table align="left" border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;"><tr><td valign="top" width="600" style="width:600px;"><![endif]-->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%; min-width:100%;" width="100%" class="textContentContainer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" class="textContent" style="padding: 0px 36px;">
|
||||
|
||||
<f:render section="Main" />
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></table>
|
||||
<!--[if (gte mso 9)|(IE)]></td></tr></table><![endif]-->
|
||||
|
||||
</td></tr>
|
||||
<!-- BODY END -->
|
||||
</table>
|
||||
</td></tr></table>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<f:spaceless><f:render section="Title" optional="true" />
|
||||
<f:render section="Main" /></f:spaceless>
|
||||
@@ -0,0 +1 @@
|
||||
<f:render section="Main"/>
|
||||
@@ -0,0 +1,34 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||
xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers"
|
||||
data-namespace-typo3-fluid="true">
|
||||
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<div class="flex items-center space-x-2">
|
||||
<f:form.textfield property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
value=""
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
placeholder="Bitte die Zeichen aus der Grafik eingeben"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"/>
|
||||
<img src="{f:uri.page(pageType:3413)}" alt="Captcha image" loading="lazy" onload="this.parentElement.classList.remove('captcha--reloading'); return false;"/>
|
||||
<f:if condition="{element.properties.showRefresh}">
|
||||
<button type="button"
|
||||
class="captcha__reload"
|
||||
aria-label="Captcha neu laden"
|
||||
data-url="{f:uri.page(pageType:3413)}"
|
||||
onclick="const div = this.parentElement; div.classList.add('captcha--reloading', 'captcha--spin'); let captchaUrl = this.dataset.url; this.previousElementSibling.setAttribute('src', captchaUrl + (/\?/.test(captchaUrl) ? '&' : '?') + 'now=' + Date.now()); setTimeout(function(){div.classList.remove('captcha--spin')},400); return false;">
|
||||
<span class="sr-only">Captcha neu laden</span>
|
||||
<svg width="31" height="28" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#000" fill-rule="evenodd">
|
||||
<path d="M10 10.7 6.3 8.5a11 11 0 0 1 20 3l2.5-.8h.4v-.3A14 14 0 0 0 3.6 7L.3 5l1.8 8.3 8-2.6ZM31 23l-1.7-8-8 2.5 3.7 2.1a10.9 10.9 0 0 1-19.8-2.3l-2 .6-1 .3a13.9 13.9 0 0 0 17 9.3 14 14 0 0 0 8.4-6.4l3.4 2Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</f:if>
|
||||
</div>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||
xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers"
|
||||
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
|
||||
data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element, doNotShowLabel: 1}" contentAs="elementContent">
|
||||
<f:if condition="{v:format.substring(start: 0, length: 1, content: '{element.label}')} == '*'">
|
||||
<f:then>
|
||||
<f:variable name="labelClass" value="ml-2 font-bold"/>
|
||||
<f:variable name="labelString" value="{v:format.substring(start: 1, content: '{element.label}')}"/>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:variable name="labelClass" value="ml-2"/>
|
||||
<f:variable name="labelString" value="{element.label}"/>
|
||||
</f:else>
|
||||
</f:if>
|
||||
<label class="inline-flex items-center">
|
||||
<f:form.checkbox
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
value="{element.properties.value}"
|
||||
class="form-checkbox"
|
||||
errorClass="form-checkbox--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
<span class="{labelClass}">{labelString}<f:if condition="{element.required}"><f:render partial="Field/Required" /></f:if></span>
|
||||
</label>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:if condition="{element.rootForm.renderingOptions.previewMode}">
|
||||
<f:then>
|
||||
<f:if condition="{element.properties.contentElementUid}">
|
||||
<f:then>
|
||||
<formvh:be.renderContentElementPreview contentElementUid="{element.properties.contentElementUid}" />
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:translate key="LLL:EXT:form/Resources/Private/Language/Database.xlf:formEditor.elements.ContentElement.selectContentElement" />
|
||||
</f:else>
|
||||
</f:if>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:if condition="{element.properties.contentElementUid}">
|
||||
<f:cObject typoscriptObjectPath="lib.tx_form.contentElementRendering">{element.properties.contentElementUid}</f:cObject>
|
||||
</f:if>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textfield
|
||||
type="date"
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textfield
|
||||
type="email"
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<f:form.validationResults for="{element.rootForm.identifier}.{element.identifier}">
|
||||
<label class="block mb-4">
|
||||
<f:if condition="{doNotShowLabel} != 1">
|
||||
<span class="{f:if(condition: '{validationResults.errors.0}', then: 'text-red-500', else: 'text-zinc-700')}">{formvh:translateElementProperty(element: element, property: 'label')}<f:if condition="{element.required}"><f:render partial="Field/Required" /></f:if></span>
|
||||
</f:if>
|
||||
{elementContent -> f:format.raw()}
|
||||
<f:if condition="{validationResults.flattenedErrors}">
|
||||
<div class="text-red-500 mt-2" role="alert">
|
||||
<f:for each="{validationResults.errors}" as="error">
|
||||
<f:format.htmlspecialchars>{formvh:translateElementError(element: element, error: error)}</f:format.htmlspecialchars>
|
||||
<br />
|
||||
</f:for>
|
||||
</div>
|
||||
</f:if>
|
||||
</label>
|
||||
</f:form.validationResults>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<fieldset id="{element.uniqueIdentifier}" class="{f:if(condition: element.properties.elementClassAttribute, then: ' {element.properties.elementClassAttribute}')}">
|
||||
<f:if condition="{element.label}">
|
||||
<legend class="pb-2 mb-2 border-b border-zinc-400">{formvh:translateElementProperty(element: element, property: 'label')}</legend>
|
||||
</f:if>
|
||||
<f:for each="{element.elements}" as="element">
|
||||
<f:render partial="{element.templateName}" arguments="{element: element}" />
|
||||
</f:for>
|
||||
</fieldset>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<nav class="form-navigation">
|
||||
<div class="button-group" role="group">
|
||||
<f:if condition="{form.previousPage}">
|
||||
<f:form.hidden property="__currentPage" value="{form.previousPage.index}" />
|
||||
<f:form.button type="button" onclick="this.form.noValidate = true; this.form.requestSubmit();" class="button bg-button" formnovalidate="formnovalidate">{formvh:translateElementProperty(element: form.currentPage, renderingOptionProperty: 'previousButtonLabel')}</f:form.button>
|
||||
</f:if>
|
||||
<f:if condition="{form.nextPage}">
|
||||
<f:then>
|
||||
<f:form.button property="__currentPage"
|
||||
value="{form.nextPage.index}"
|
||||
data="{'form-debounce-target': 'button'}"
|
||||
class="button bg-button">
|
||||
{formvh:translateElementProperty(element: form.currentPage, renderingOptionProperty: 'nextButtonLabel')}
|
||||
</f:form.button>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:form.button property="__currentPage"
|
||||
value="{form.pages -> f:count()}"
|
||||
data="{'form-debounce-target': 'button'}"
|
||||
class="button bg-button">
|
||||
{formvh:translateElementProperty(element: form, renderingOptionProperty: 'submitButtonLabel')}
|
||||
</f:form.button>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</div>
|
||||
</nav>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element, doNotShowLabel: 1}" contentAs="elementContent">
|
||||
<label class="inline-flex items-center">
|
||||
<f:form.checkbox
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-checkbox"
|
||||
errorClass="form-checkbox--has-error"
|
||||
value="{element.properties.value}"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
<span class="ml-2">
|
||||
{formvh:translateElementProperty(element: element, property: 'label') -> f:format.raw()} <f:link.typolink class="underline" parameter="{element.properties.pageUid}" target="_blank">{formvh:translateElementProperty(element: element, property: 'linkText')}</f:link.typolink><f:if condition="{element.required}"> <f:render partial="Field/Required" /></f:if>
|
||||
</span>
|
||||
</label>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||
xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers"
|
||||
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
|
||||
data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element, doNotShowLabel: 1}" contentAs="elementContent">
|
||||
<span class="block text-zinc-700 mb-1">{formvh:translateElementProperty(element: element, property: 'label')}<f:if condition="{element.required}"><f:render partial="Field/Required" /></f:if></span>
|
||||
<f:for each="{element.properties.options}" as="label" key="value" iteration="idIterator">
|
||||
<f:if condition="{v:format.substring(start: 0, length: 1, content: '{label}')} == '*'">
|
||||
<f:then>
|
||||
<f:variable name="labelClass" value="ml-2 font-bold"/>
|
||||
<f:variable name="labelString" value="{v:format.substring(start: 1, content: '{label}')}"/>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:variable name="labelClass" value="ml-2"/>
|
||||
<f:variable name="labelString" value="{label}"/>
|
||||
</f:else>
|
||||
</f:if>
|
||||
<div>
|
||||
<label class="inline-flex items-center">
|
||||
<f:form.checkbox
|
||||
property="{element.identifier}"
|
||||
multiple="1"
|
||||
id="{element.uniqueIdentifier}-{idIterator.index}"
|
||||
value="{value}"
|
||||
class="form-checkbox"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
<span class="{labelClass}">{labelString}<f:if condition="{element.required}"><f:render partial="Field/Required" /></f:if></span>
|
||||
</label>
|
||||
</div>
|
||||
</f:for>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:if condition="{element.properties.prependOptionLabel}">
|
||||
<f:then>
|
||||
<f:form.select
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
options="{formvh:translateElementProperty(element: element, property: 'options')}"
|
||||
multiple="multiple"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
prependOptionLabel="{formvh:translateElementProperty(element: element, property: 'prependOptionLabel')}"
|
||||
prependOptionValue="{formvh:translateElementProperty(element: element, property: 'prependOptionValue')}"
|
||||
/>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:form.select
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
options="{formvh:translateElementProperty(element: element, property: 'options')}"
|
||||
multiple="multiple"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:else>
|
||||
</f:if>
|
||||
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textfield
|
||||
type="number"
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element, doNotShowLabel: 1}" contentAs="elementContent">
|
||||
<span class="block text-zinc-700 mb-1">{formvh:translateElementProperty(element: element, property: 'label')}<f:if condition="{element.required}"><f:render partial="Field/Required" /></f:if></span>
|
||||
<f:for each="{element.properties.options}" as="label" key="value" iteration="idIterator">
|
||||
<div>
|
||||
<label for="{element.uniqueIdentifier}-{idIterator.index}" class="inline-flex items-center">
|
||||
<f:form.radio
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}-{idIterator.index}"
|
||||
value="{value}"
|
||||
class="form-radio"
|
||||
errorClass="form-radio--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
<span class="ml-2">{formvh:translateElementProperty(element: element, property: '{0: \'options\', 1: value}')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</f:for>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:if condition="{element.properties.prependOptionLabel}">
|
||||
<f:then>
|
||||
<f:form.select
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
options="{formvh:translateElementProperty(element: element, property: 'options')}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
prependOptionLabel="{formvh:translateElementProperty(element: element, property: 'prependOptionLabel')}"
|
||||
prependOptionValue="{formvh:translateElementProperty(element: element, property: 'prependOptionValue')}"
|
||||
/>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:form.select
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
options="{formvh:translateElementProperty(element: element, property: 'options')}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<f:comment>Same spinner as MyE&P (_partials/_spinner.html.twig), drawn in currentColor</f:comment>
|
||||
<svg class="{class}" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
||||
<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>
|
||||
</svg>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:if condition="{element.properties.text}">
|
||||
<p{f:if(condition: element.properties.elementClassAttribute, then: ' class="{element.properties.elementClassAttribute}"')}>
|
||||
<f:link.typolink parameter="{element.properties.targetPage}">
|
||||
{formvh:translateElementProperty(element: element, property: 'text') -> f:format.nl2br()}
|
||||
</f:link.typolink>
|
||||
</p>
|
||||
</f:if>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textfield
|
||||
type="tel"
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textfield
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{element}">
|
||||
<f:render partial="Field/Field" arguments="{element: element}" contentAs="elementContent">
|
||||
<f:form.textarea
|
||||
property="{element.identifier}"
|
||||
id="{element.uniqueIdentifier}"
|
||||
class="form-field"
|
||||
errorClass="form-field--has-error"
|
||||
rows="{element.properties.rows}"
|
||||
cols="{element.properties.cols}"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: element, property: 'fluidAdditionalAttributes')}"
|
||||
/>
|
||||
</f:render>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<formvh:renderRenderable renderable="{form}">
|
||||
<formvh:form
|
||||
object="{form}"
|
||||
action="{form.renderingOptions.controllerAction}"
|
||||
method="{form.renderingOptions.httpMethod}"
|
||||
id="{form.identifier}"
|
||||
section="{form.identifier}"
|
||||
enctype="{form.renderingOptions.httpEnctype}"
|
||||
addQueryString="{form.renderingOptions.addQueryString}"
|
||||
argumentsToBeExcludedFromQueryString="{form.renderingOptions.argumentsToBeExcludedFromQueryString}"
|
||||
additionalParams="{form.renderingOptions.additionalParams}"
|
||||
additionalAttributes="{formvh:translateElementProperty(element: form, property: 'fluidAdditionalAttributes')}"
|
||||
data="{'controller': 'form-debounce', 'action': 'form-debounce#debounce', 'form-debounce-disabled-class': 'cursor-not-allowed'}"
|
||||
hx-boost="true"
|
||||
hx-target="closest .form-island"
|
||||
hx-select=".form-island"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
hx-indicator="closest .form-island"
|
||||
>
|
||||
<f:render partial="{form.currentPage.templateName}" arguments="{page: form.currentPage}" />
|
||||
<div class="actions">
|
||||
<f:render partial="Form/Navigation" arguments="{form: form}" />
|
||||
</div>
|
||||
</formvh:form>
|
||||
</formvh:renderRenderable>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<f:comment>
|
||||
The forms of a page as the form page type (/path/form) returns them: htmx swaps this element as
|
||||
a whole (see Form.html). While a request runs, htmx sets htmx-request on it and the overlay
|
||||
covers the form. Styled here and not in the theme build: the static pages around the island
|
||||
load the CSS of the TYPO3 10 export. The spinner takes the button colour of each theme.
|
||||
</f:comment>
|
||||
<div class="form-island">
|
||||
{forms -> f:format.raw()}
|
||||
<div class="form-island__loading" aria-hidden="true">
|
||||
<f:render partial="Form/Spinner" arguments="{class: 'form-island__spinner'}" />
|
||||
</div>
|
||||
<style>
|
||||
.form-island { position: relative; }
|
||||
.form-island__loading { position: absolute; inset: 0; z-index: 10; display: flex; align-items: center; justify-content: center; background: rgb(255 255 255 / .7); visibility: hidden; opacity: 0; transition: opacity .15s, visibility .15s; }
|
||||
.form-island.htmx-request .form-island__loading { visibility: visible; opacity: 1; }
|
||||
.form-island__spinner { width: 6rem; height: 6rem; color: rgb(24 82 123); }
|
||||
.sbw .form-island__spinner { color: rgb(233 28 38); }
|
||||
.snz .form-island__spinner { color: rgb(249 195 61); }
|
||||
.suz .form-island__spinner { color: rgb(130 195 198); }
|
||||
.uch .form-island__spinner { color: rgb(25 163 220); }
|
||||
.ser .form-island__spinner { color: rgb(222 44 27); }
|
||||
</style>
|
||||
</div>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:formvh="http://typo3.org/ns/TYPO3/CMS/Form/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
|
||||
<f:flashMessages />
|
||||
<f:if condition="{formConfiguration}">
|
||||
<div class="py-8">
|
||||
<formvh:render overrideConfiguration="{formConfiguration}" />
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
</html>
|
||||
@@ -12,11 +12,14 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"b13/container": "^4.1",
|
||||
"blueways/bw-captcha": "^5.4",
|
||||
"ep/ep-products": "@dev",
|
||||
"tritum/form-element-linked-checkbox": "^6.0",
|
||||
"typo3/cms-core": "^13.4",
|
||||
"typo3/cms-extbase": "^13.4",
|
||||
"typo3/cms-fluid": "^13.4",
|
||||
"typo3/cms-fluid-styled-content": "^13.4",
|
||||
"typo3/cms-form": "^13.4",
|
||||
"typo3/cms-frontend": "^13.4"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -15,6 +15,7 @@ $boot = function () {
|
||||
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['ep-minimal'] = 'EXT:ep_theme/Configuration/RTE/Minimal.yaml';
|
||||
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['namespaces']['ep'] = ['EP\\EpTheme\\ViewHelpers'];
|
||||
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['layoutRootPaths'][700] = 'EXT:ep_theme/Resources/Private/Layouts/Email/';
|
||||
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['format'] = 'html';
|
||||
|
||||
$metaTagManagerRegistry = GeneralUtility::makeInstance(MetaTagManagerRegistry::class);
|
||||
|
||||
Reference in new issue
Block a user