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:
frommeandClaude Opus 5.5 committed 2026-10-22 12:10:43 +02:00
1 parent 59e4a2d49b
commit 925e24397b
54 files changed
+2500 -3

No files matched your search

Generated
+272 -1
View File
@@ -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",
+2
View File
@@ -282,6 +282,8 @@ routeEnhancers:
/: 0
/json: 1701
/html: 1702
/form: 1704
/captcha: 3413
.json: 1803
.ical: 1710
sitemap.xml: 1533906435
+8
View File
@@ -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
+2
View File
@@ -66,6 +66,8 @@ routeEnhancers:
/: 0
/json: 1701
/html: 1702
/form: 1704
/captcha: 3413
sitemap.xml: 1533906435
yoast-snippetpreview.json: 1480321830
routes:
+2
View File
@@ -50,6 +50,8 @@ routeEnhancers:
/: 0
/json: 1701
/html: 1702
/form: 1704
/captcha: 3413
sitemap.xml: 1533906435
yoast-snippetpreview.json: 1480321830
routes:
+2
View File
@@ -50,6 +50,8 @@ routeEnhancers:
/: 0
/json: 1701
/html: 1702
/form: 1704
/captcha: 3413
sitemap.xml: 1533906435
yoast-snippetpreview.json: 1480321830
routes:
+2
View File
@@ -50,6 +50,8 @@ routeEnhancers:
/: 0
/json: 1701
/html: 1702
/form: 1704
/captcha: 3413
sitemap.xml: 1533906435
yoast-snippetpreview.json: 1480321830
routes:
+5 -2
View File
@@ -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,
+3
View File
@@ -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' => [
+7
View File
@@ -25,6 +25,13 @@ return [
],
],
],
'EXTCONF' => [
'lang' => [
'availableLanguages' => [
'de',
],
],
],
'EXTENSIONS' => [
'backend' => [
'backendFavicon' => '',
+7
View File
@@ -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: &quot;Helvetica Neue&quot;, 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&amp;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>
+3
View File
@@ -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": {
+1
View File
@@ -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);