diff --git a/composer.lock b/composer.lock index cd70bfe..19f46ad 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "schneider.maik@me.com", + "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": "g.passault@gmail.com", + "homepage": "http://www.gregwar.com/" + }, + { + "name": "Jeremy Livingston", + "email": "jeremy.j.livingston@gmail.com" + } + ], + "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": "schneider.maik@me.com" + } + ], + "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": "bjoern.jacob@tritum.de", + "homepage": "https://www.tritum.de", + "role": "Developer" + }, + { + "name": "Elias Häußler", + "email": "elias@haeussler.dev", + "homepage": "https://haeussler.dev", + "role": "Developer" + }, + { + "name": "dreistrom.land", + "email": "hello@dreistrom.land", + "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", diff --git a/config/sites/ep-reisen/config.yaml b/config/sites/ep-reisen/config.yaml index 5e3dadb..2492f0f 100644 --- a/config/sites/ep-reisen/config.yaml +++ b/config/sites/ep-reisen/config.yaml @@ -282,6 +282,8 @@ routeEnhancers: /: 0 /json: 1701 /html: 1702 + /form: 1704 + /captcha: 3413 .json: 1803 .ical: 1710 sitemap.xml: 1533906435 diff --git a/config/sites/ser/config.yaml b/config/sites/ser/config.yaml index 6c14a7b..f0e85d1 100644 --- a/config/sites/ser/config.yaml +++ b/config/sites/ser/config.yaml @@ -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 diff --git a/config/sites/ski-boarderweek/config.yaml b/config/sites/ski-boarderweek/config.yaml index 383cf3d..14dc166 100644 --- a/config/sites/ski-boarderweek/config.yaml +++ b/config/sites/ski-boarderweek/config.yaml @@ -66,6 +66,8 @@ routeEnhancers: /: 0 /json: 1701 /html: 1702 + /form: 1704 + /captcha: 3413 sitemap.xml: 1533906435 yoast-snippetpreview.json: 1480321830 routes: diff --git a/config/sites/ski-wochenenden/config.yaml b/config/sites/ski-wochenenden/config.yaml index 6a7479e..0956cae 100644 --- a/config/sites/ski-wochenenden/config.yaml +++ b/config/sites/ski-wochenenden/config.yaml @@ -50,6 +50,8 @@ routeEnhancers: /: 0 /json: 1701 /html: 1702 + /form: 1704 + /captcha: 3413 sitemap.xml: 1533906435 yoast-snippetpreview.json: 1480321830 routes: diff --git a/config/sites/snowzone/config.yaml b/config/sites/snowzone/config.yaml index 3edbe70..9471f7a 100644 --- a/config/sites/snowzone/config.yaml +++ b/config/sites/snowzone/config.yaml @@ -50,6 +50,8 @@ routeEnhancers: /: 0 /json: 1701 /html: 1702 + /form: 1704 + /captcha: 3413 sitemap.xml: 1533906435 yoast-snippetpreview.json: 1480321830 routes: diff --git a/config/sites/unichamp/config.yaml b/config/sites/unichamp/config.yaml index a8bd037..52469b7 100644 --- a/config/sites/unichamp/config.yaml +++ b/config/sites/unichamp/config.yaml @@ -50,6 +50,8 @@ routeEnhancers: /: 0 /json: 1701 /html: 1702 + /form: 1704 + /captcha: 3413 sitemap.xml: 1533906435 yoast-snippetpreview.json: 1480321830 routes: diff --git a/config/system/additional.php b/config/system/additional.php index 4cd8155..13c0dce 100644 --- a/config/system/additional.php +++ b/config/system/additional.php @@ -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, diff --git a/config/system/secrets.example.php b/config/system/secrets.example.php index 00b65da..e20bb6c 100644 --- a/config/system/secrets.example.php +++ b/config/system/secrets.example.php @@ -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' => [ diff --git a/config/system/settings.php b/config/system/settings.php index d5d44bb..8c63d0a 100644 --- a/config/system/settings.php +++ b/config/system/settings.php @@ -25,6 +25,13 @@ return [ ], ], ], + 'EXTCONF' => [ + 'lang' => [ + 'availableLanguages' => [ + 'de', + ], + ], + ], 'EXTENSIONS' => [ 'backend' => [ 'backendFavicon' => '', diff --git a/deploy.php b/deploy.php index e507a64..f9e7026 100644 --- a/deploy.php +++ b/deploy.php @@ -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', ]); diff --git a/packages/ep_products/Classes/EpTeam/ApiClient.php b/packages/ep_products/Classes/EpTeam/ApiClient.php new file mode 100644 index 0000000..b348592 --- /dev/null +++ b/packages/ep_products/Classes/EpTeam/ApiClient.php @@ -0,0 +1,244 @@ +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 ''; + } + + if ('' === $body) { + return ''; + } + + 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); + } + } +} diff --git a/packages/ep_products/Classes/EpTeam/ApiException.php b/packages/ep_products/Classes/EpTeam/ApiException.php new file mode 100644 index 0000000..e0d7d2c --- /dev/null +++ b/packages/ep_products/Classes/EpTeam/ApiException.php @@ -0,0 +1,7 @@ + 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)); + } +} diff --git a/packages/ep_theme/Classes/Form/AddReferrerFinisher.php b/packages/ep_theme/Classes/Form/AddReferrerFinisher.php new file mode 100644 index 0000000..678a441 --- /dev/null +++ b/packages/ep_theme/Classes/Form/AddReferrerFinisher.php @@ -0,0 +1,76 @@ +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 + ); + } +} diff --git a/packages/ep_theme/Classes/Form/ApplicationApiFinisher.php b/packages/ep_theme/Classes/Form/ApplicationApiFinisher.php new file mode 100644 index 0000000..7335626 --- /dev/null +++ b/packages/ep_theme/Classes/Form/ApplicationApiFinisher.php @@ -0,0 +1,74 @@ +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; + } +} diff --git a/packages/ep_theme/Classes/Form/BpnApiFinisher.php b/packages/ep_theme/Classes/Form/BpnApiFinisher.php new file mode 100644 index 0000000..af71fef --- /dev/null +++ b/packages/ep_theme/Classes/Form/BpnApiFinisher.php @@ -0,0 +1,28 @@ +buildPayload(); + if ([] !== $values) { + $this->apiClient->registerAddress($values); + } + } catch (\Throwable $e) { + $this->logWarning('Bpn API finisher request failed.', [ + 'exception' => $e, + ]); + } + } +} diff --git a/packages/ep_theme/Classes/Middleware/HtmxRedirectMiddleware.php b/packages/ep_theme/Classes/Middleware/HtmxRedirectMiddleware.php new file mode 100644 index 0000000..3714d05 --- /dev/null +++ b/packages/ep_theme/Classes/Middleware/HtmxRedirectMiddleware.php @@ -0,0 +1,35 @@ +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')); + } +} diff --git a/packages/ep_theme/Classes/Middleware/ReferrerMiddleware.php b/packages/ep_theme/Classes/Middleware/ReferrerMiddleware.php new file mode 100644 index 0000000..489ed54 --- /dev/null +++ b/packages/ep_theme/Classes/Middleware/ReferrerMiddleware.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/packages/ep_theme/Configuration/RequestMiddlewares.php b/packages/ep_theme/Configuration/RequestMiddlewares.php index c36a623..5633005 100644 --- a/packages/ep_theme/Configuration/RequestMiddlewares.php +++ b/packages/ep_theme/Configuration/RequestMiddlewares.php @@ -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', + ], + ], ], ]; diff --git a/packages/ep_theme/Configuration/Services.yaml b/packages/ep_theme/Configuration/Services.yaml index 10d6f85..a7eced5 100644 --- a/packages/ep_theme/Configuration/Services.yaml +++ b/packages/ep_theme/Configuration/Services.yaml @@ -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 diff --git a/packages/ep_theme/Configuration/TypoScript/Extensions/Form.typoscript b/packages/ep_theme/Configuration/TypoScript/Extensions/Form.typoscript new file mode 100644 index 0000000..7a5ed25 --- /dev/null +++ b/packages/ep_theme/Configuration/TypoScript/Extensions/Form.typoscript @@ -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 +} diff --git a/packages/ep_theme/Configuration/TypoScript/Page/Form.typoscript b/packages/ep_theme/Configuration/TypoScript/Page/Form.typoscript new file mode 100644 index 0000000..857250d --- /dev/null +++ b/packages/ep_theme/Configuration/TypoScript/Page/Form.typoscript @@ -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 + } + } +} diff --git a/packages/ep_theme/Configuration/Yaml/FormSetup.yaml b/packages/ep_theme/Configuration/Yaml/FormSetup.yaml new file mode 100644 index 0000000..1577e2e --- /dev/null +++ b/packages/ep_theme/Configuration/Yaml/FormSetup.yaml @@ -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' diff --git a/packages/ep_theme/Resources/Private/Forms/ApplicationForm.form.yaml b/packages/ep_theme/Resources/Private/Forms/ApplicationForm.form.yaml new file mode 100644 index 0000000..be73b78 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Forms/ApplicationForm.form.yaml @@ -0,0 +1,378 @@ + +renderingOptions: + submitButtonLabel: Absenden +type: Form +identifier: applicationform +label: Bewerbungsformular +prototypeName: applicationform +finishers: + - + options: + subject: 'Bewerbung von {text-1}' + recipientAddress: team@ep-reisen.de + recipientName: 'E&P Reisen' + senderAddress: team@ep-reisen.de + 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 diff --git a/packages/ep_theme/Resources/Private/Forms/ContactForm.form.yaml b/packages/ep_theme/Resources/Private/Forms/ContactForm.form.yaml new file mode 100644 index 0000000..3a0ec95 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Forms/ContactForm.form.yaml @@ -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: info@ep-reisen.de + recipientName: 'E&P Reisen' + senderAddress: info@ep-reisen.de + 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' diff --git a/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.html b/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.html new file mode 100644 index 0000000..bd3f29f --- /dev/null +++ b/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.html @@ -0,0 +1,388 @@ + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + +
+ +
+ +
+ + +
+
+ + +
+ + +
+
+ + + + + + + + +
+ + + +
+ + +
+
+ + +
+
+
+ + + diff --git a/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.txt b/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.txt new file mode 100644 index 0000000..885ad11 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Layouts/Email/SystemEmail.txt @@ -0,0 +1,2 @@ + + diff --git a/packages/ep_theme/Resources/Private/Layouts/Form/Default.html b/packages/ep_theme/Resources/Private/Layouts/Form/Default.html new file mode 100644 index 0000000..8027861 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Layouts/Form/Default.html @@ -0,0 +1 @@ + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Captcha.html b/packages/ep_theme/Resources/Private/Partials/Form/Captcha.html new file mode 100644 index 0000000..7984a2c --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Captcha.html @@ -0,0 +1,34 @@ + + + + +
+ + Captcha image + + + +
+
+
+ + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Checkbox.html b/packages/ep_theme/Resources/Private/Partials/Form/Checkbox.html new file mode 100644 index 0000000..3c4e0bd --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Checkbox.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/ContentElement.html b/packages/ep_theme/Resources/Private/Partials/Form/ContentElement.html new file mode 100644 index 0000000..f38f99a --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/ContentElement.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + {element.properties.contentElementUid} + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Date.html b/packages/ep_theme/Resources/Private/Partials/Form/Date.html new file mode 100644 index 0000000..834ca46 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Date.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Email.html b/packages/ep_theme/Resources/Private/Partials/Form/Email.html new file mode 100644 index 0000000..590dbdb --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Email.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Field/Field.html b/packages/ep_theme/Resources/Private/Partials/Form/Field/Field.html new file mode 100644 index 0000000..60fb3b4 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Field/Field.html @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Fieldset.html b/packages/ep_theme/Resources/Private/Partials/Form/Fieldset.html new file mode 100644 index 0000000..7e99729 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Fieldset.html @@ -0,0 +1,12 @@ + + +
+ + {formvh:translateElementProperty(element: element, property: 'label')} + + + + +
+
+ diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Form/Navigation.html b/packages/ep_theme/Resources/Private/Partials/Form/Form/Navigation.html new file mode 100644 index 0000000..9e25f5e --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Form/Navigation.html @@ -0,0 +1,28 @@ + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/LinkedCheckbox.html b/packages/ep_theme/Resources/Private/Partials/Form/LinkedCheckbox.html new file mode 100644 index 0000000..3d52470 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/LinkedCheckbox.html @@ -0,0 +1,19 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/MultiCheckbox.html b/packages/ep_theme/Resources/Private/Partials/Form/MultiCheckbox.html new file mode 100644 index 0000000..2569d5e --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/MultiCheckbox.html @@ -0,0 +1,35 @@ + + + + {formvh:translateElementProperty(element: element, property: 'label')} + + + + + + + + + + + +
+ +
+
+
+
+ diff --git a/packages/ep_theme/Resources/Private/Partials/Form/MultiSelect.html b/packages/ep_theme/Resources/Private/Partials/Form/MultiSelect.html new file mode 100644 index 0000000..4d1440d --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/MultiSelect.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Number.html b/packages/ep_theme/Resources/Private/Partials/Form/Number.html new file mode 100644 index 0000000..f323739 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Number.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/RadioButton.html b/packages/ep_theme/Resources/Private/Partials/Form/RadioButton.html new file mode 100644 index 0000000..9e2064a --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/RadioButton.html @@ -0,0 +1,22 @@ + + + + {formvh:translateElementProperty(element: element, property: 'label')} + +
+ +
+
+
+
+ diff --git a/packages/ep_theme/Resources/Private/Partials/Form/SingleSelect.html b/packages/ep_theme/Resources/Private/Partials/Form/SingleSelect.html new file mode 100644 index 0000000..4e1da4a --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/SingleSelect.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Spinner.html b/packages/ep_theme/Resources/Private/Partials/Form/Spinner.html new file mode 100644 index 0000000..bee44b8 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Spinner.html @@ -0,0 +1,15 @@ + +Same spinner as MyE&P (_partials/_spinner.html.twig), drawn in currentColor + + + + + + + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/StaticText.html b/packages/ep_theme/Resources/Private/Partials/Form/StaticText.html new file mode 100644 index 0000000..74f182d --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/StaticText.html @@ -0,0 +1,11 @@ + + + + + + {formvh:translateElementProperty(element: element, property: 'text') -> f:format.nl2br()} + +

+
+
+ diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Telephone.html b/packages/ep_theme/Resources/Private/Partials/Form/Telephone.html new file mode 100644 index 0000000..aed976c --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Telephone.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Text.html b/packages/ep_theme/Resources/Private/Partials/Form/Text.html new file mode 100644 index 0000000..9d78acd --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Text.html @@ -0,0 +1,13 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Partials/Form/Textarea.html b/packages/ep_theme/Resources/Private/Partials/Form/Textarea.html new file mode 100644 index 0000000..b6e522d --- /dev/null +++ b/packages/ep_theme/Resources/Private/Partials/Form/Textarea.html @@ -0,0 +1,15 @@ + + + + + + + diff --git a/packages/ep_theme/Resources/Private/Templates/Form/Form.html b/packages/ep_theme/Resources/Private/Templates/Form/Form.html new file mode 100644 index 0000000..4f44172 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Form/Form.html @@ -0,0 +1,28 @@ + + + + +
+ +
+
+
+ diff --git a/packages/ep_theme/Resources/Private/Templates/Form/Island.html b/packages/ep_theme/Resources/Private/Templates/Form/Island.html new file mode 100644 index 0000000..d4a4ab0 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Form/Island.html @@ -0,0 +1,25 @@ + + + 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. + +
+ {forms -> f:format.raw()} + + +
+ diff --git a/packages/ep_theme/Resources/Private/Templates/Form/Render.html b/packages/ep_theme/Resources/Private/Templates/Form/Render.html new file mode 100644 index 0000000..cf636c9 --- /dev/null +++ b/packages/ep_theme/Resources/Private/Templates/Form/Render.html @@ -0,0 +1,10 @@ + + + + +
+ +
+
+ + diff --git a/packages/ep_theme/composer.json b/packages/ep_theme/composer.json index cacc62f..7a7c208 100644 --- a/packages/ep_theme/composer.json +++ b/packages/ep_theme/composer.json @@ -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": { diff --git a/packages/ep_theme/ext_localconf.php b/packages/ep_theme/ext_localconf.php index ff3e807..42a086a 100644 --- a/packages/ep_theme/ext_localconf.php +++ b/packages/ep_theme/ext_localconf.php @@ -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);