diff --git a/assets/app.js b/assets/app.js index a0cf73a..873ed6d 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,9 +1,8 @@ import './bootstrap.js' import './styles/app.css' -import '@iframe-resizer/child' - import htmx from 'htmx.org' + window.htmx = htmx htmx.config.includeIndicatorStyles = false htmx.config.historyEnabled = false @@ -11,4 +10,4 @@ htmx.config.historyCacheSize = 0 htmx.config.allowScriptTags = false htmx.config.withCredentials = true htmx.config.selfRequestsOnly = false -htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s) \ No newline at end of file +htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s) diff --git a/assets/controllers/booking_controller.js b/assets/controllers/booking_controller.js deleted file mode 100644 index 0698ef5..0000000 --- a/assets/controllers/booking_controller.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller } from '@hotwired/stimulus' - -export default class extends Controller { - static targets = ['field'] - static values = { availabilities: Object } - - toggle(event) { - let field = event.target - let id = field.value - let availabilities = this.availabilitiesValue - if (field.checked) { - availabilities[id].available-- - } else { - availabilities[id].available++ - } - field.disabled = 0 >= availabilities[id].available - field.classList.toggle('cursor-not-allowed', field.disabled) - this.availabilitiesValue = availabilities - } - - availabilitiesValueChanged(availabilities) { - this.fieldTargets.forEach((field) => { - let id = field.value - if (false === field.checked && 0 >= availabilities[id].available) { - field.disabled = true - } - }) - } -} \ No newline at end of file diff --git a/assets/controllers/gravatar_controller.js b/assets/controllers/gravatar_controller.js deleted file mode 100644 index 3f67814..0000000 --- a/assets/controllers/gravatar_controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import {Controller} from '@hotwired/stimulus' - -export default class extends Controller { - static values = {url: String, alt: String} - static classes = ['image'] - - connect() { - const img = new Image() - img.onload = () => { - img.classList.add(...this.imageClasses) - this.element.innerHTML = '' - this.element.appendChild(img) - } - img.alt = this.altValue - img.src = this.urlValue - } -} diff --git a/assets/controllers/iframe_controller.js b/assets/controllers/iframe_controller.js deleted file mode 100644 index 39139cf..0000000 --- a/assets/controllers/iframe_controller.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Controller } from '@hotwired/stimulus' - -export default class extends Controller { - static values = { offsetTop: { type: Number}} - connect() { - if ('parentIframe' in window) { - window.iFrameResizer = { - onReady: function () { - window.parentIFrame.scrollTo(0, this.offsetTopValue) - } - } - } - } -} diff --git a/assets/controllers/loading_controller.js b/assets/controllers/loading_controller.js index 96a483e..893d075 100644 --- a/assets/controllers/loading_controller.js +++ b/assets/controllers/loading_controller.js @@ -13,11 +13,17 @@ export default class extends Controller { this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this) this.boundHandleAfterRequest = this.handleAfterRequest.bind(this) this.boundHandleTimeout = this.handleTimeout.bind(this) + this.boundHandlePageShow = this.handlePageShow.bind(this) + this.boundHandleHistoryRestore = this.handleHistoryRestore.bind(this) // Listen to HTMX events document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest) document.body.addEventListener('htmx:timeout', this.boundHandleTimeout) + document.body.addEventListener('htmx:historyRestore', this.boundHandleHistoryRestore) + + // Listen for browser back/forward navigation + window.addEventListener('pageshow', this.boundHandlePageShow) } disconnect() { @@ -25,6 +31,8 @@ export default class extends Controller { document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest) document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout) + document.body.removeEventListener('htmx:historyRestore', this.boundHandleHistoryRestore) + window.removeEventListener('pageshow', this.boundHandlePageShow) // Clear any pending timeout if (this.debounceTimeout) { @@ -76,6 +84,18 @@ export default class extends Controller { alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.') } + handlePageShow(event) { + // event.persisted is true when page is restored from bfcache (back/forward navigation) + if (event.persisted) { + this.hide() + } + } + + handleHistoryRestore(event) { + // HTMX history restore - hide loading indicator + this.hide() + } + show() { this.isVisible = true this.indicatorTarget.classList.remove(this.hiddenClass) diff --git a/assets/controllers/toggle_controller.js b/assets/controllers/toggle_controller.js index 65461c8..3801722 100644 --- a/assets/controllers/toggle_controller.js +++ b/assets/controllers/toggle_controller.js @@ -1,8 +1,8 @@ -import { Controller } from '@hotwired/stimulus' +import {Controller} from '@hotwired/stimulus' export default class extends Controller { - static classes = ['closed'] - static targets = ['toggle', 'icon'] + static classes = ['closed', 'open', 'iconOpen'] + static targets = ['content', 'icon', 'container'] static values = { open: { type: Boolean, @@ -15,7 +15,7 @@ export default class extends Controller { } initialize() { - this.target = this.hasToggleTarget ? this.toggleTarget : this.element + this.target = this.hasContentTarget ? this.contentTarget : this.element // Restore state from storage if storageKey is provided if (this.hasStorageKey()) { @@ -39,7 +39,16 @@ export default class extends Controller { this.target.classList.toggle(this.closedClass, false === open) if (this.hasIconTarget) { - this.iconTarget.classList.toggle('rotate-90', true === open) + let iconClass = this.hasIconOpenClass ? this.iconOpenClass : 'rotate-90' + this.iconTarget.classList.toggle(iconClass, true === open) + } + + // Toggle class on container element (or controller element) if specified + if (this.hasOpenClass) { + const container = this.hasContainerTarget ? this.containerTarget : this.element + this.openClasses.forEach(cls => { + container.classList.toggle(cls, true === open) + }) } // Save state to storage if storageKey is provided diff --git a/assets/images/bg_1.jpg b/assets/images/bg_1.jpg new file mode 100644 index 0000000..c26c769 Binary files /dev/null and b/assets/images/bg_1.jpg differ diff --git a/assets/images/bg_2.jpg b/assets/images/bg_2.jpg new file mode 100644 index 0000000..05b64b5 Binary files /dev/null and b/assets/images/bg_2.jpg differ diff --git a/assets/images/bg_3.jpg b/assets/images/bg_3.jpg new file mode 100644 index 0000000..20ddbac Binary files /dev/null and b/assets/images/bg_3.jpg differ diff --git a/assets/images/bg_4.jpg b/assets/images/bg_4.jpg new file mode 100644 index 0000000..3bb0388 Binary files /dev/null and b/assets/images/bg_4.jpg differ diff --git a/assets/images/bg_5.jpg b/assets/images/bg_5.jpg new file mode 100644 index 0000000..da05c91 Binary files /dev/null and b/assets/images/bg_5.jpg differ diff --git a/assets/images/icons.svg b/assets/images/icons.svg deleted file mode 100644 index e43afb4..0000000 --- a/assets/images/icons.svg +++ /dev/null @@ -1,236 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/styles/_base.css b/assets/styles/_base.css index 878768c..728e3ee 100644 --- a/assets/styles/_base.css +++ b/assets/styles/_base.css @@ -40,3 +40,19 @@ .htmx-request.htmx-indicator{ @apply visible; } + +.bg-outer { + @apply bg-blend-multiply bg-right-top bg-cover; +} + +.bg-outer--1 { + background-image: url('../images/bg_4.jpg'), linear-gradient(311.39deg, #0070E0 10.55%, #165883 53.93%, #18527B 80.73%); +} + +.bg-outer--2 { + background-image: url('../images/bg_5.jpg'), linear-gradient(311.39deg, #0070E0 10.55%, #165883 53.93%, #18527B 80.73%); +} + +.bg-inner { + background-image: linear-gradient(311.39deg, #0070E0cc 16.35%, #165883cc 41.93%, #18527Bcc 56.29%); +} diff --git a/assets/styles/_components.css b/assets/styles/_components.css index b0ca78a..44a3cf9 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,7 +1,6 @@ @import "components/typography.css"; @import "components/forms.css"; @import "components/button.css"; -@import "components/menu.css"; @import "components/tooltip.css"; -@import "components/table-responsive.css"; @import "components/toast.css"; +@import "components/pagination.css"; diff --git a/assets/styles/components/button.css b/assets/styles/components/button.css index 09bf382..d09e6b4 100644 --- a/assets/styles/components/button.css +++ b/assets/styles/components/button.css @@ -1,7 +1,8 @@ .button { - @apply inline-flex items-center justify-center space-x-2 px-4 py-2 md:px-8 cursor-pointer; + @apply inline-flex items-center justify-center space-x-2 h-10 leading-10 px-4 md:px-8 cursor-pointer; @apply uppercase leading-none text-center; @apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light; + @apply rounded-md; } .button--small { @@ -12,38 +13,24 @@ @apply block w-full; } -.bg-button { - @apply bg-none bg-primary-dark text-white; - @apply hover:bg-badge-gradient; +.button--secondary { + @apply bg-none bg-primary-light text-white; + @apply hover:bg-primary-light/80; } - -.bg-button--active, -.bg-button--secondary { - @apply bg-badge-gradient text-white; - @apply hover:bg-none hover:bg-pink; +.button--primary { + @apply bg-pink text-white; + @apply hover:bg-none hover:bg-pink/80; } -.bg-button--muted { +.button--muted { @apply bg-zinc-300 hover:bg-zinc-300; } -.bg-button--dark { +.button--dark { @apply bg-primary-dark; } -.bg-button--light { - @apply bg-white border-2; - @apply font-bold; - @apply border-primary-light text-primary; -} - -.bg-button--light:hover, -.bg-button--light.button--active { - @apply bg-white; - @apply border-secondary text-secondary; -} - .button[disabled] { @apply cursor-not-allowed; } diff --git a/assets/styles/components/forms.css b/assets/styles/components/forms.css index ebd5f09..eb7d901 100644 --- a/assets/styles/components/forms.css +++ b/assets/styles/components/forms.css @@ -4,7 +4,13 @@ label.required:after { } .form-field { - @apply border-zinc-400 focus:border-zinc-800 ring-0 focus:outline-2 focus:outline-offset-2 focus:outline-primary-light mt-1 block w-full; + @apply border-zinc-400 ring-0 mt-1 block w-full rounded-md; + @apply focus:outline-primary-light focus:border-zinc-800 focus:outline-2 focus:outline-offset-2; +} + +#participant-form .form-field, +#form-payment .form-field { + @apply bg-primary-bg border-primary-bg; } .form-field--has-error { diff --git a/assets/styles/components/menu.css b/assets/styles/components/menu.css deleted file mode 100644 index ca8398e..0000000 --- a/assets/styles/components/menu.css +++ /dev/null @@ -1,11 +0,0 @@ -.menu--main { - @apply flex items-center m-0 divide-x divide-white; -} - -.menu--main a { - @apply block py-2 px-4 text-white hover:bg-secondary hover:text-zinc-800 text-sm md:text-base; -} - -.menu--main .current a { - @apply bg-secondary text-zinc-800; -} \ No newline at end of file diff --git a/assets/styles/components/pagination.css b/assets/styles/components/pagination.css new file mode 100644 index 0000000..bcaf9ed --- /dev/null +++ b/assets/styles/components/pagination.css @@ -0,0 +1,60 @@ +:root { + --pagination-skew: 2rem; + --pagination-border-width: 1px; +} + +.pagination { + @apply flex h-12 lg:h-16 overflow-hidden bg-primary-bg shadow-md; +} + +.pagination-item { + flex: 1 1 0; + margin-right: calc(-1 * var(--pagination-skew)); + @apply relative; +} + +.pagination-item:last-child { + @apply mr-0; +} + +/* Outer clip-path for button shape */ +.pagination-item:first-child { + clip-path: polygon(0 0, calc(100% - var(--pagination-skew)) 0, 100% 100%, 0 100%); +} + +.pagination-item:not(:first-child):not(:last-child) { + clip-path: polygon(0 0, calc(100% - var(--pagination-skew)) 0, 100% 100%, var(--pagination-skew) 100%); +} + +.pagination-item:last-child { + clip-path: polygon(0 0, 100% 0, 100% 100%, var(--pagination-skew) 100%); +} + +/* Border and content wrappers */ +.pagination-item__border { + @apply w-full h-full flex items-center justify-center bg-primary-dark/10; +} + +.pagination-item__inner { + @apply w-full h-full flex items-center justify-center; + @apply lg:text-2xl font-bold; +} + +a.pagination-item__border > .pagination-item__inner:hover { + @apply bg-primary-light text-white; +} + +/* Inner clip-path for border effect */ +.pagination-item:first-child .pagination-item__inner { + clip-path: polygon(0 0, calc(100% - var(--pagination-skew)) 0, 100% 100%, 0 100%); +} + +.pagination-item:not(:first-child) .pagination-item__inner { + /* No left/bottom-left border on overlapping edge to prevent double borders */ + clip-path: polygon(0 0, calc(100% - var(--pagination-skew) - var(--pagination-border-width)) 0, calc(100% - var(--pagination-border-width)) 100%, var(--pagination-skew) 100%); +} + +.pagination-item:last-child .pagination-item__inner { + /* No left/bottom-left border on overlapping edge, no right border */ + clip-path: polygon(0 0, 100% 0, 100% 100%, var(--pagination-skew) 100%); +} diff --git a/assets/styles/components/table-responsive.css b/assets/styles/components/table-responsive.css deleted file mode 100644 index 8fe8e9f..0000000 --- a/assets/styles/components/table-responsive.css +++ /dev/null @@ -1,20 +0,0 @@ -.responsive { - @apply text-sm md:text-base; -} - -.responsive thead { - @apply hidden md:table-header-group; -} - -.responsive tbody tr { - @apply block mb-2 md:table-row; -} - -.responsive td { - @apply grid grid-cols-2 gap-x-2 last:col-span-2 md:table-cell; -} - -.responsive td:before { - content: attr(data-label); - @apply block font-bold last:hidden md:hidden; -} diff --git a/composer.json b/composer.json index 572056f..f1f603d 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.3", - "knplabs/knp-menu-bundle": "^3.4", "league/flysystem-bundle": "^3.4", "league/flysystem-sftp-v3": "^3.29", "league/oauth2-server-bundle": "^1.0", diff --git a/composer.lock b/composer.lock index 0cf4be2..cb8d58e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "44832f4adf896d77d2fd6eec915e7739", + "content-hash": "cdf22f45562d34493b3d37f46bea70bc", "packages": [ { "name": "brick/math", @@ -1454,147 +1454,6 @@ ], "time": "2025-03-06T22:45:56+00:00" }, - { - "name": "knplabs/knp-menu", - "version": "v3.8.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/KnpMenu.git", - "reference": "79d325909a1d428a93f1a0f55e90177830e283bb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/KnpMenu/zipball/79d325909a1d428a93f1a0f55e90177830e283bb", - "reference": "79d325909a1d428a93f1a0f55e90177830e283bb", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "conflict": { - "symfony/http-foundation": "<5.4", - "twig/twig": "<2.16" - }, - "require-dev": { - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.6", - "psr/container": "^1.0 || ^2.0", - "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", - "symfony/phpunit-bridge": "^7.0", - "symfony/routing": "^5.4 || ^6.0 || ^7.0", - "twig/twig": "^2.16 || ^3.0" - }, - "suggest": { - "twig/twig": "for the TwigRenderer and the integration with your templates" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Menu\\": "src/Knp/Menu" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "KnpLabs", - "homepage": "https://knplabs.com" - }, - { - "name": "Christophe Coevoet", - "email": "stof@notk.org" - }, - { - "name": "The Community", - "homepage": "https://github.com/KnpLabs/KnpMenu/contributors" - } - ], - "description": "An object oriented menu library", - "homepage": "https://knplabs.com", - "keywords": [ - "menu", - "tree" - ], - "support": { - "issues": "https://github.com/KnpLabs/KnpMenu/issues", - "source": "https://github.com/KnpLabs/KnpMenu/tree/v3.8.0" - }, - "time": "2025-06-13T15:03:33+00:00" - }, - { - "name": "knplabs/knp-menu-bundle", - "version": "v3.7.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/KnpMenuBundle.git", - "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/KnpMenuBundle/zipball/aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", - "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", - "shasum": "" - }, - "require": { - "knplabs/knp-menu": "^3.8", - "php": "^8.1", - "symfony/config": "^6.4 | ^7.0 | ^8.0", - "symfony/dependency-injection": "^6.4 | ^7.0 | ^8.0", - "symfony/deprecation-contracts": "^2.5 | ^3.3", - "symfony/http-kernel": "^6.4 | ^7.0 | ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5 | ^11.5 | ^12.4", - "symfony/expression-language": "^6.4 | ^7.0 | ^8.0", - "symfony/phpunit-bridge": "^7.0 | ^8.0", - "symfony/templating": "^6.4 | ^7.0 | ^8.0" - }, - "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Bundle\\MenuBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Knplabs", - "homepage": "http://knplabs.com" - }, - { - "name": "Christophe Coevoet", - "email": "stof@notk.org" - }, - { - "name": "Symfony Community", - "homepage": "https://github.com/KnpLabs/KnpMenuBundle/contributors" - } - ], - "description": "This bundle provides an integration of the KnpMenu library", - "keywords": [ - "menu" - ], - "support": { - "issues": "https://github.com/KnpLabs/KnpMenuBundle/issues", - "source": "https://github.com/KnpLabs/KnpMenuBundle/tree/v3.7.0" - }, - "time": "2025-11-30T08:30:04+00:00" - }, { "name": "lcobucci/clock", "version": "3.3.1", diff --git a/config/bundles.php b/config/bundles.php index bf83205..82998e3 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -13,7 +13,6 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], - Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true], League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true], Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true], diff --git a/config/services.yaml b/config/services.yaml index 48b7273..d6135aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -45,14 +45,6 @@ services: arguments: $intlExtension: '@twig.extension.intl' - App\Menu\MenuBuilder: - arguments: - $factory: '@knp_menu.factory' - tags: - - name: knp_menu.menu_builder - method: createMainMenu - alias: main - App\Command\GenerateKeysCommand: arguments: $path: '%path_to_keys%' diff --git a/package-lock.json b/package-lock.json index 7581060..d643421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "name": "myep-next", "license": "WTFPL", "dependencies": { - "@iframe-resizer/child": "^5.3.2", "tippy.js": "^6.3.7", "toastify-js": "^1.12.0" }, @@ -1638,19 +1637,6 @@ "@hotwired/stimulus": ">= 3.0" } }, - "node_modules/@iframe-resizer/child": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/@iframe-resizer/child/-/child-5.5.7.tgz", - "integrity": "sha512-+/t5E9/wbB+sWg6xM6fRWvNZ48WcZSJXHwXt58EdsWnb1vW+N4wf0Tp6Bwq9JPN4GR64yjXAhb0bBq67ECr/lg==", - "license": "GPL-3.0", - "dependencies": { - "auto-console-group": "1.2.11" - }, - "funding": { - "type": "individual", - "url": "https://iframe-resizer.com/pricing/" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2750,12 +2736,6 @@ "node": ">= 4.0.0" } }, - "node_modules/auto-console-group": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/auto-console-group/-/auto-console-group-1.2.11.tgz", - "integrity": "sha512-/RFCswabfQZR4CDYser0V+AC+6+Q1ro2+RrP0AinOXDYnWsm3w14uc2MVdEaaSCDfpiTtETfahE9N7z9Yb1JiA==", - "license": "MIT" - }, "node_modules/autoprefixer": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", diff --git a/package.json b/package.json index 558af04..6ca55d0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "cy:open": "cypress open" }, "dependencies": { - "@iframe-resizer/child": "^5.3.2", "tippy.js": "^6.3.7", "toastify-js": "^1.12.0" } diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index e5f4653..e61f1be 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -68,17 +68,30 @@ class BookingDataProcessor $participantData = ParticipantDto::fromPersonalData($participant); $participantData->index = $index; - // First participant (applicant): copy address from applicant if participant address is empty - // BPN API may return full address only in but minimal/empty address in - // Only copy if first participant has no street (indicating empty/incomplete address) - // This allows applicant and first participant to be different people with different addresses - if (0 === $index && null !== $booking->applicant->address) { - $isEmpty = null === $participantData->address - || null === $participantData->address->street - || '' === trim($participantData->address->street); + // First participant (applicant): copy data from applicant if participant data is empty + // BPN API may return full data only in but minimal/empty data in + if (0 === $index) { + // Copy address if first participant has no street (indicating empty/incomplete address) + // This allows applicant and first participant to be different people with different addresses + if (null !== $booking->applicant->address) { + $isEmpty = null === $participantData->address + || null === $participantData->address->street + || '' === trim($participantData->address->street); - if ($isEmpty) { - $participantData->address = clone $booking->applicant->address; + if ($isEmpty) { + $participantData->address = clone $booking->applicant->address; + } + } + + // Copy body dimensions from applicant if not present in participant + if (null === $participantData->height && null !== $booking->applicant->height) { + $participantData->height = $booking->applicant->height; + } + if (null === $participantData->weight && null !== $booking->applicant->weight) { + $participantData->weight = $booking->applicant->weight; + } + if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) { + $participantData->shoeSize = $booking->applicant->shoeSize; } } diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index 78ae4df..27773f7 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -304,6 +304,17 @@ class BookingPayloadBuilder } } + // Add body dimensions + if (null !== $participant->height) { + $participantData['sonstiges1'] = $participant->height; + } + if (null !== $participant->weight) { + $participantData['sonstiges2'] = $participant->weight; + } + if (null !== $participant->shoeSize) { + $participantData['sonstiges3'] = $participant->shoeSize; + } + // Add wishes (room remarks and license plate) if (null !== $participant->remarksRoom || null !== $participant->licensePlate) { $participantData['wünsche'] = []; diff --git a/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php index 38e3b70..b0b46b2 100644 --- a/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php +++ b/src/BusProNet/DataProcessor/PersonalDataSynchronizer.php @@ -20,7 +20,7 @@ class PersonalDataSynchronizer /** * Updates participant personal data from form input. * - * Only processes participants with status 'F' (active/confirmed participants). + * Processes all active participants (status 'F' or 'A'). Skips canceled participants (status 'S'). * Updates all personal data fields and communication information. * * IMPORTANT: The applicant's address must never be modified. This method updates @@ -32,7 +32,8 @@ class PersonalDataSynchronizer public function updateParticipantPersonalData(array $participants, Booking $bookingData): void { foreach ($participants as $participant) { - if ('F' !== $participant->status) { + // Skip canceled participants (status 'S') + if ('S' === $participant->status) { continue; } diff --git a/src/BusProNet/Model/PersonalData.php b/src/BusProNet/Model/PersonalData.php index ab067c7..ffa75f9 100644 --- a/src/BusProNet/Model/PersonalData.php +++ b/src/BusProNet/Model/PersonalData.php @@ -27,7 +27,7 @@ class PersonalData public ?string $name = null; #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] - public string $firstName = ''; + public ?string $firstName = ''; public ?string $salutation = null; public ?string $title = null; @@ -55,6 +55,11 @@ class PersonalData $this->communication = new Communication(); } + public function getFullName(): string + { + return sprintf('%s %s', $this->firstName, $this->name); + } + /** * Converts the personal data to API payload format. * diff --git a/src/Controller/Account/IndexController.php b/src/Controller/Account/IndexController.php new file mode 100644 index 0000000..0dd7c3e --- /dev/null +++ b/src/Controller/Account/IndexController.php @@ -0,0 +1,20 @@ +render('account/index.html.twig'); + } +} diff --git a/src/Controller/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php similarity index 98% rename from src/Controller/PersonalDataController.php rename to src/Controller/Account/PersonalDataController.php index 0f0a2bc..5fde2c3 100644 --- a/src/Controller/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -1,6 +1,6 @@ redirectToRoute('app_personal_data'); } - return $this->render('personal_data/index.html.twig', [ + return $this->render('account/personal_data.html.twig', [ 'personalData' => $personalData, 'personalDataForm' => $personalDataForm->createView(), ]); diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index 096d538..88af424 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -9,6 +9,7 @@ use App\Exception\HotelNotFoundException; use App\Exception\HotelNotInTravelException; use App\Exception\NoRoomsAvailableException; use App\Exception\TravelNotFoundException; +use App\Htmx\HxTrait; use App\Service\BookingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -24,6 +25,8 @@ use Symfony\Component\Routing\Attribute\Route; */ class IndexController extends AbstractController { + use HxTrait; + public function __construct( private readonly BookingService $bookingService, private readonly AgencyLoader $agencyLoader, @@ -113,18 +116,22 @@ class IndexController extends AbstractController #[Route('/bookings/cancel', name: 'app_booking_cancel')] public function cancel(Request $request): Response { - // Clear the booking session - $this->bookingService->clearBookingSession($request); + if (Request::METHOD_POST === $request->getMethod()) { + // Clear the booking session + $this->bookingService->clearBookingSession($request); - // Clear the security target path to prevent redirect loop after login - // Without this, logging in after cancel would redirect back to a stale booking URL - $request->getSession()->remove('_security.main.target_path'); + // Clear the security target path to prevent redirect loop after login + // Without this, logging in after cancel would redirect back to a stale booking URL + $request->getSession()->remove('_security.main.target_path'); - // Add a flash message to inform the user - $this->addFlash('info', 'Buchung abgebrochen.'); + // Add a flash message to inform the user + $this->addFlash('info', 'Buchung abgebrochen.'); - // Redirect to login page - return $this->redirectToRoute('app_login'); + // Redirect to login page + return $this->hxRedirect($request, $this->generateUrl('app_login')); + } + + return $this->render('booking/modal_cancel.html.twig'); } /** diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 72ec7a8..ca91152 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -17,7 +17,8 @@ class SecurityController extends AbstractController public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response { // Check if this is a booking flow (BookingDto exists in session) - $isBookingFlow = null !== $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY); + $bookingDto = $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY); + $isBookingFlow = null !== $bookingDto; // If authenticated and in booking flow, proceed to Step 1 if (null !== $this->getUser() && true === $isBookingFlow) { @@ -52,6 +53,9 @@ class SecurityController extends AbstractController return $this->render($template, [ 'last_username' => $lastUsername, 'error' => $error, + 'travel_title' => $bookingDto?->travel->label, + 'travel_date_from' => $bookingDto?->travel->dateFrom, + 'travel_date_to' => $bookingDto?->travel->dateTo, ]); } diff --git a/src/Form/Model/BankAccountDto.php b/src/Form/Model/BankAccountDto.php index 81a928b..d5b8b55 100644 --- a/src/Form/Model/BankAccountDto.php +++ b/src/Form/Model/BankAccountDto.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Form\Model; +use App\BusProNet\Model\BankAccount; use Symfony\Component\Validator\Constraints as Assert; /** @@ -11,11 +12,11 @@ use Symfony\Component\Validator\Constraints as Assert; */ class BankAccountDto { - #[Assert\NotBlank(message: 'Bitte geben Sie Ihre IBAN ein.')] + #[Assert\NotBlank(message: 'Bitte gib deine IBAN ein.')] #[Assert\Iban(message: 'Die eingegebene IBAN ist ungültig.')] public ?string $iban = null; - #[Assert\NotBlank(message: 'Bitte geben Sie den Kontoinhaber ein.')] + #[Assert\NotBlank(message: 'Bitte gib den Kontoinhaber ein.')] #[Assert\Length( min: 2, max: 70, @@ -30,10 +31,10 @@ class BankAccountDto )] public ?string $bankName = null; - #[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')] + #[Assert\IsTrue(message: 'Bitte akzeptiere das SEPA-Mandat.')] public bool $sepaMandateAccepted = false; - public static function fromBankAccount(\App\BusProNet\Model\BankAccount $bankAccount): static + public static function fromBankAccount(BankAccount $bankAccount): static { $instance = new static(); $instance->iban = $bankAccount->iban; diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 1498caa..5f2b18d 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -41,7 +41,7 @@ class BookingDto #[Assert\Choice( choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT], - message: 'Bitte wählen Sie eine gültige Zahlungsart.' + message: 'Bitte wähle eine gültige Zahlungsart.' )] public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER; @@ -255,7 +255,7 @@ class BookingDto } if (null === $this->bankAccount) { - $context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.') + $context->buildViolation('Bitte gib deine Bankverbindung an.') ->atPath('bankAccount') ->addViolation(); @@ -263,19 +263,19 @@ class BookingDto } if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) { - $context->buildViolation('Bitte geben Sie Ihre IBAN ein.') + $context->buildViolation('Bitte gib deine IBAN ein.') ->atPath('bankAccount.iban') ->addViolation(); } if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) { - $context->buildViolation('Bitte geben Sie den Kontoinhaber ein.') + $context->buildViolation('Bitte gib den Kontoinhaber ein.') ->atPath('bankAccount.accountHolder') ->addViolation(); } if (false === $this->bankAccount->sepaMandateAccepted) { - $context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.') + $context->buildViolation('Bitte akzeptiere das SEPA-Mandat.') ->atPath('bankAccount.sepaMandateAccepted') ->addViolation(); } diff --git a/src/Form/Model/BookingSummaryDto.php b/src/Form/Model/BookingSummaryDto.php index d350470..656f0b8 100644 --- a/src/Form/Model/BookingSummaryDto.php +++ b/src/Form/Model/BookingSummaryDto.php @@ -15,7 +15,8 @@ class BookingSummaryDto /** * @param array $selectedRooms Selected room DTOs from booking * @param int $participantCount Total participant count from room capacity - * @param string $totalPrice Formatted total price (e.g., "1.234,56 €") + * @param float $totalPrice Total price before voucher deductions + * @param float $payableAmount Amount after voucher deductions * @param array $groupedSelectedRooms Rooms grouped by participant assignments * @param array $assignmentCounts Room ID to participant count mapping * @param array $pricingData Detailed pricing breakdown @@ -24,7 +25,8 @@ class BookingSummaryDto public function __construct( public readonly array $selectedRooms, public readonly int $participantCount, - public readonly string $totalPrice, + public readonly float $totalPrice, + public readonly float $payableAmount, public readonly array $groupedSelectedRooms, public readonly array $assignmentCounts, public readonly array $pricingData, diff --git a/src/Form/PersonalDataType.php b/src/Form/PersonalDataType.php index fb72e8c..29138d5 100644 --- a/src/Form/PersonalDataType.php +++ b/src/Form/PersonalDataType.php @@ -35,7 +35,6 @@ class PersonalDataType extends AbstractType ->add('email', EmailType::class, [ 'label' => 'E-Mail', 'property_path' => 'communication.email', - 'sanitize_html' => true, ]) ->add('phone', TextType::class, [ 'label' => 'Telefon', diff --git a/src/Form/RegistrationType.php b/src/Form/RegistrationType.php index b1befe0..42bf260 100644 --- a/src/Form/RegistrationType.php +++ b/src/Form/RegistrationType.php @@ -29,10 +29,10 @@ class RegistrationType extends AbstractType ]) ->add('name', TextType::class, [ 'label' => 'Nachname', + 'sanitize_html' => true, ]) ->add('email', EmailType::class, [ 'label' => 'E-Mail', - 'sanitize_html' => true, ]) ; } diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php index 844869e..ac9a5da 100644 --- a/src/Form/RoomSelectType.php +++ b/src/Form/RoomSelectType.php @@ -8,6 +8,8 @@ use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; class RoomSelectType extends AbstractType @@ -20,15 +22,8 @@ class RoomSelectType extends AbstractType $data = $event->getData(); $form = $event->getForm(); - // Build label with pricing - $label = 'Anzahl '.$data->roomLabel; - if (null !== $data->roomPrice) { - $formattedPrice = number_format((float) $data->roomPrice, 2, ',', '.'); - $label .= sprintf(' (€%s pro Person)', $formattedPrice); - } - $form->add('quantity', StepSelectChoiceType::class, [ - 'label' => $label, + 'label' => false, 'required' => true, 'min_value' => 0, 'max_value' => $data->maxQuantity, @@ -37,6 +32,19 @@ class RoomSelectType extends AbstractType }); } + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $data = $form->getData(); + + $view->vars['label_room'] = $data->roomLabel; + $view->vars['label_price'] = null; + + if (null !== $data->roomPrice) { + $formattedPrice = number_format((float) $data->roomPrice, 2, ',', '.'); + $view->vars['label_price'] = sprintf(' %s € pro Person', $formattedPrice); + } + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 66ff2c5..d43d017 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -167,7 +167,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; @@ -205,7 +205,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; @@ -251,7 +251,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; @@ -294,7 +294,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; @@ -353,7 +353,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_label' => fn (?Service $service) => $service?->label, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; @@ -396,7 +396,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $bookingDto, $participantIndex ), - 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_label' => fn (Service $service) => $service?->label, 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, @@ -427,7 +427,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Rückfahrt', 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), - 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_label' => fn (Service $service) => $service?->label, 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, @@ -653,41 +653,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); } - /** - * Format transportation service labels with type indicator and pricing. - * - * Creates user-friendly labels for transportation services that include: - * - Transportation type icon (🚌 for bus, 🚗 for car) - * - Service name - * - Pricing (with discount indication for negative prices) - * - Availability warning for limited services - * - * @param Service $service The transportation service to format - * - * @return string The formatted transportation service label - */ - private function formatTransportationServiceLabel(Service $service): string - { - $label = $service->label; - - if (null === $service->price || 0.0 === $service->price) { - return $service->label; - } - - if ($service->price > 0) { - $label .= sprintf(' (€%s)', number_format($service->price, 2, ',', '.')); - } else { - $label .= sprintf(' (-%s€ Rabatt)', number_format(abs($service->price), 2, ',', '.')); - } - - // Add availability warning if limited - if (null !== $service->available && $service->available <= 5) { - $label .= sprintf(' (nur %d verfügbar)', $service->available); - } - - return $label; - } - private function formatPickupLabelWithPrice(?Pickup $pickup): string { if (null === $pickup) { @@ -935,11 +900,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider */ private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array { - // Only apply filtering in create mode - if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) { - return $services; - } - // Separate PKW/CAR from other services (BUS, etc.) $pkwServices = []; $otherServices = []; diff --git a/src/Menu/MenuBuilder.php b/src/Menu/MenuBuilder.php deleted file mode 100644 index 0ff7485..0000000 --- a/src/Menu/MenuBuilder.php +++ /dev/null @@ -1,49 +0,0 @@ -factory->createItem('root', [ - 'childrenAttributes' => [ - 'class' => 'menu menu--main', - ], - ]); - - $menu->addChild('Meine Daten', [ - 'route' => 'app_personal_data', - 'linkAttributes' => [ - 'data-action' => 'loading#toggle', - ], - ]); - $menu->addChild('Meine Buchungen', [ - 'route' => 'app_bookings', - 'linkAttributes' => [ - 'data-action' => 'loading#toggle', - ], - 'extras' => [ - 'routes' => [ - 'app_booking_edit', - ], - ], - ]); - $menu->addChild('Logout', [ - 'route' => 'app_logout', - 'linkAttributes' => [ - 'data-action' => 'loading#toggle', - ], - ]); - - return $menu; - } -} diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index b6ee59d..57eb3d9 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -112,7 +112,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return new RedirectResponse($targetPath); } - return new RedirectResponse($this->urlGenerator->generate('app_personal_data')); + return new RedirectResponse($this->urlGenerator->generate('app_account')); } private function collectRoles(CrmAttributes $crmAttributes): array diff --git a/src/Service/BookingSummaryDataService.php b/src/Service/BookingSummaryDataService.php index 8f47351..1e2aaa7 100644 --- a/src/Service/BookingSummaryDataService.php +++ b/src/Service/BookingSummaryDataService.php @@ -69,10 +69,14 @@ class BookingSummaryDataService // Calculate participant count from room capacity (source of truth) $participantCount = $this->calculateParticipantCountFromRooms($bookingDto); + // Calculate payable amount after voucher deductions + $payableAmount = $this->calculatePayableAmount($bookingDto, $pricingData['grandTotal'], $participantPrices); + return new BookingSummaryDto( selectedRooms: $selectedRooms, participantCount: $participantCount, - totalPrice: number_format($totalPrice, 2, ',', '.').' €', + totalPrice: $pricingData['grandTotal'], + payableAmount: $payableAmount, groupedSelectedRooms: $groupedSelectedRooms, assignmentCounts: $roomCounts, pricingData: $pricingData, @@ -81,13 +85,35 @@ class BookingSummaryDataService } /** - * Calculates participant count from room selections. + * Calculates the payable amount after voucher deductions. * - * This is the source of truth for participant count, calculated by - * multiplying each selected room's quantity by its maximum capacity (maxPax). + * @param array $participantPrices Prices per participant for percentage voucher calculation + */ + private function calculatePayableAmount(BookingDto $bookingDto, float $grandTotal, array $participantPrices): float + { + $acceptedVouchers = $bookingDto->getAcceptedVouchers($participantPrices); + + if (null === $acceptedVouchers) { + return $grandTotal; + } + + return max(0.0, $grandTotal - $acceptedVouchers->getTotalDiscount()); + } + + /** + * Calculates participant count. + * + * In edit mode, counts actual participants. In create mode, calculates + * from room selections by multiplying quantity by maximum capacity (maxPax). */ private function calculateParticipantCountFromRooms(BookingDto $bookingDto): int { + // In edit mode, use actual participant count + if (BookingDto::MODE_EDIT === $bookingDto->getMode()) { + return count($bookingDto->participants); + } + + // In create mode, calculate from room selections $totalCapacity = 0; $availableRooms = $bookingDto->travel->getAvailableRooms(); diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index b6e70ff..86ee014 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -24,12 +24,11 @@ class AppExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]), new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']), new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]), new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']), new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']), - new TwigFunction('gravatar_url', [AppRuntime::class, 'getGravatarUrl']), + new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']), ]; } } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index e102964..5081871 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -52,14 +52,6 @@ class AppRuntime implements RuntimeExtensionInterface return $this->intlExtension->formatCurrency($amount, 'EUR'); } - public function renderIcon(Environment $environment, string $icon, string $classes = 'w-5 h-5'): string - { - return $environment->render('_partials/_icon.html.twig', [ - 'icon' => $icon, - 'class' => $classes, - ]); - } - public function mapStatus(string $status): string { $status = strtoupper($status); @@ -171,18 +163,35 @@ class AppRuntime implements RuntimeExtensionInterface } /** - * Generates a Gravatar URL for the given email address. + * Recursively collects labels of invalid form fields. * - * @param string $email The email address - * @param int $size The size of the avatar in pixels (default: 80) + * @param FormView $form The form view to check * - * @return string The Gravatar URL + * @return array Array of invalid field labels */ - public function getGravatarUrl(string $email, int $size = 80): string + public function collectInvalidFieldLabels(FormView $form): array { - $hash = md5(strtolower(trim($email))); + $labels = []; - return sprintf('https://www.gravatar.com/avatar/%s?s=%d&d=404', $hash, $size); + foreach ($form->children as $child) { + $hasErrors = \count($child->vars['errors']) > 0; + $hasInvalidChildren = false === $child->vars['valid']; + + if ($hasErrors || $hasInvalidChildren) { + $hasChildren = \count($child->children) > 0; + $isExpanded = $child->vars['expanded'] ?? false; + + if ($hasChildren && false === $isExpanded && false === $hasErrors) { + // Nested form without own errors (e.g., address, bodyDimensions) - recurse + $labels = array_merge($labels, $this->collectInvalidFieldLabels($child)); + } else { + // Leaf field, expanded choice, or compound field with own errors + $labels[] = $child->vars['label'] ?? $child->vars['name']; + } + } + } + + return $labels; } /** diff --git a/symfony.lock b/symfony.lock index 7dd436e..0674ae6 100644 --- a/symfony.lock +++ b/symfony.lock @@ -47,9 +47,6 @@ ".php-cs-fixer.dist.php" ] }, - "knplabs/knp-menu-bundle": { - "version": "v3.4.2" - }, "league/flysystem-bundle": { "version": "3.4", "recipe": { diff --git a/tailwind.config.js b/tailwind.config.js index 5fe2b84..30696f6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,36 +1,36 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - './templates/**/*.twig', - './src/**/*.php', - ], - safelist: ['rotate-90'], - theme: { - container: { - center: true, - padding: '2rem', + content: [ + './templates/**/*.twig', + './src/**/*.php', + ], + safelist: ['rotate-90'], + theme: { + container: { + center: true, + padding: '2rem', + }, + extend: { + fontFamily: { + sans: ['Lato', 'sans-serif'], + }, + colors: { + primary: '#3d8ccb', + secondary: '#f9c700', + 'primary-bg': '#ebf3fa', + 'primary-light': '#0070e0', + 'primary-medium': '#165883', + 'primary-dark': '#18527b', + pink: '#fa1a8c', + }, + backgroundImage: { + 'badge-gradient': 'linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%)', + 'brand-gradient': 'linear-gradient(to top right, #009fff, #165883, #18527b)', + }, + }, }, - extend: { - fontFamily: { - sans: ['Lato', 'sans-serif'], - }, - colors: { - primary: '#3d8ccb', - secondary: '#f9c700', - 'primary-bg': '#ebf3fa', - 'primary-light': '#0080ff', - 'primary-medium': '#165883', - 'primary-dark': '#18527b', - pink: '#fa1a8c', - }, - backgroundImage: { - 'badge-gradient': 'linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%)', - 'brand-gradient': 'linear-gradient(to top right, #009fff, #165883, #18527b)', - }, - }, - }, - plugins: [ - require('@tailwindcss/forms'), - ], + plugins: [ + require('@tailwindcss/forms'), + ], } diff --git a/templates/_partials/_alert.html.twig b/templates/_partials/_alert.html.twig index 36b0941..86a9a16 100644 --- a/templates/_partials/_alert.html.twig +++ b/templates/_partials/_alert.html.twig @@ -1,32 +1,32 @@ {% if modal is not defined %} {% set modal = false %} {% endif %} -