feat: implement screendesign

This commit is contained in:
Björn Fromme
2025-12-06 15:02:33 +01:00
parent b206c3990b
commit bfb1a04ea0
84 changed files with 2807 additions and 2603 deletions
+2 -3
View File
@@ -1,9 +1,8 @@
import './bootstrap.js' import './bootstrap.js'
import './styles/app.css' import './styles/app.css'
import '@iframe-resizer/child'
import htmx from 'htmx.org' import htmx from 'htmx.org'
window.htmx = htmx window.htmx = htmx
htmx.config.includeIndicatorStyles = false htmx.config.includeIndicatorStyles = false
htmx.config.historyEnabled = false htmx.config.historyEnabled = false
@@ -11,4 +10,4 @@ htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false htmx.config.allowScriptTags = false
htmx.config.withCredentials = true htmx.config.withCredentials = true
htmx.config.selfRequestsOnly = false htmx.config.selfRequestsOnly = false
htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s) htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s)
-29
View File
@@ -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
}
})
}
}
-17
View File
@@ -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
}
}
-14
View File
@@ -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)
}
}
}
}
}
+20
View File
@@ -13,11 +13,17 @@ export default class extends Controller {
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this) this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this) this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
this.boundHandleTimeout = this.handleTimeout.bind(this) this.boundHandleTimeout = this.handleTimeout.bind(this)
this.boundHandlePageShow = this.handlePageShow.bind(this)
this.boundHandleHistoryRestore = this.handleHistoryRestore.bind(this)
// Listen to HTMX events // Listen to HTMX events
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest) document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
document.body.addEventListener('htmx:timeout', this.boundHandleTimeout) 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() { disconnect() {
@@ -25,6 +31,8 @@ export default class extends Controller {
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest) document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout) document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout)
document.body.removeEventListener('htmx:historyRestore', this.boundHandleHistoryRestore)
window.removeEventListener('pageshow', this.boundHandlePageShow)
// Clear any pending timeout // Clear any pending timeout
if (this.debounceTimeout) { 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.') 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() { show() {
this.isVisible = true this.isVisible = true
this.indicatorTarget.classList.remove(this.hiddenClass) this.indicatorTarget.classList.remove(this.hiddenClass)
+14 -5
View File
@@ -1,8 +1,8 @@
import { Controller } from '@hotwired/stimulus' import {Controller} from '@hotwired/stimulus'
export default class extends Controller { export default class extends Controller {
static classes = ['closed'] static classes = ['closed', 'open', 'iconOpen']
static targets = ['toggle', 'icon'] static targets = ['content', 'icon', 'container']
static values = { static values = {
open: { open: {
type: Boolean, type: Boolean,
@@ -15,7 +15,7 @@ export default class extends Controller {
} }
initialize() { 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 // Restore state from storage if storageKey is provided
if (this.hasStorageKey()) { if (this.hasStorageKey()) {
@@ -39,7 +39,16 @@ export default class extends Controller {
this.target.classList.toggle(this.closedClass, false === open) this.target.classList.toggle(this.closedClass, false === open)
if (this.hasIconTarget) { 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 // Save state to storage if storageKey is provided
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

-236
View File
@@ -1,236 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="icon-dots" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</symbol>
<symbol id="icon-feedback" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</symbol>
<symbol id="icon-login" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</symbol>
<symbol id="icon-logout" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</symbol>
<symbol id="icon-alert" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</symbol>
<symbol id="icon-close" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</symbol>
<symbol id="icon-back" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
</symbol>
<symbol id="icon-list" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</symbol>
<symbol id="icon-menu" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</symbol>
<symbol id="icon-edit" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</symbol>
<symbol id="icon-plus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</symbol>
<symbol id="icon-minus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
</symbol>
<symbol id="icon-search" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</symbol>
<symbol id="icon-user" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</symbol>
<symbol id="icon-users" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</symbol>
<symbol id="icon-delete" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</symbol>
<symbol id="icon-check" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</symbol>
<symbol id="icon-upload" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</symbol>
<symbol id="icon-download" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</symbol>
<symbol id="icon-sort" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</symbol>
<symbol id="icon-arrow-left" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
</symbol>
<symbol id="icon-arrow-right" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
</symbol>
<symbol id="icon-document" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</symbol>
<symbol id="icon-filter" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</symbol>
<symbol id="icon-refresh" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</symbol>
<symbol id="icon-settings" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</symbol>
<symbol id="icon-at" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" />
</symbol>
<symbol id="icon-mail" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</symbol>
<symbol id="icon-house" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
</symbol>
<symbol id="icon-phone" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</symbol>
<symbol id="icon-mobile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</symbol>
<symbol id="icon-calendar" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" />
</symbol>
<symbol id="icon-hand" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.05 4.575a1.575 1.575 0 10-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 013.15 0v1.5m-3.15 0l.075 5.925m3.075.75V4.575m0 0a1.575 1.575 0 013.15 0V15M6.9 7.575a1.575 1.575 0 10-3.15 0v8.175a6.75 6.75 0 006.75 6.75h2.018a5.25 5.25 0 003.712-1.538l1.732-1.732a5.25 5.25 0 001.538-3.712l.003-2.024a.668.668 0 01.198-.471 1.575 1.575 0 10-2.228-2.228 3.818 3.818 0 00-1.12 2.687M6.9 7.575V12m6.27 4.318A4.49 4.49 0 0116.35 15m.002 0h-.002" />
</symbol>
<symbol id="icon-copy" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6" />
</symbol>
<symbol id="icon-photo" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</symbol>
<symbol id="icon-print" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="icon-locked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</symbol>
<symbol id="icon-unlocked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</symbol>
<symbol id="icon-mask" fill="currentColor" stroke="none" viewBox="0 0 576 512">
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M288 64C39.52 64 0 182.1 0 273.5C0 379.5 78.8 448 176 448c27.33 0 51.21-6.516 66.11-36.79l19.93-40.5C268.3 358.6 278.1 352.4 288 352.1c9.9 .3711 19.7 6.501 25.97 18.63l19.93 40.5C348.8 441.5 372.7 448 400 448c97.2 0 176-68.51 176-174.5C576 182.1 536.5 64 288 64zM400 400c-18.12 0-19.56-2.924-23.04-9.986l-20.35-41.33c-13.87-26.86-38.85-43.53-66.82-44.57L288 304L286.2 304.1c-27.96 1.049-52.94 17.71-67.24 45.41l-19.93 40.49C195.6 397.1 194.1 400 176 400c-75.36 0-128-52.04-128-126.5C48 229.3 48 112 288 112s240 117.3 240 161.5C528 347.1 475.4 400 400 400zM160 192C124.7 192 96 220.7 96 256s28.65 64 64 64c35.35 0 64-28.65 64-64S195.3 192 160 192zM416 192c-35.35 0-64 28.65-64 64s28.65 64 64 64c35.35 0 64-28.65 64-64S451.3 192 416 192z"/>
</symbol>
<symbol id="icon-excel" fill="currentColor" stroke="none" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM229.1 233.3L192 280.9L154.9 233.3C146.8 222.8 131.8 220.9 121.3 229.1C110.8 237.2 108.9 252.3 117.1 262.8L161.6 320l-44.53 57.25c-8.156 10.47-6.25 25.56 4.188 33.69C125.7 414.3 130.8 416 135.1 416c7.156 0 14.25-3.188 18.97-9.25L192 359.1l37.06 47.65C233.8 412.8 240.9 416 248 416c5.125 0 10.31-1.656 14.72-5.062c10.44-8.125 12.34-23.22 4.188-33.69L222.4 320l44.53-57.25c8.156-10.47 6.25-25.56-4.188-33.69C252.2 220.9 237.2 222.8 229.1 233.3z"/>
</symbol>
<symbol id="icon-pdf" fill="currentColor" stroke="none" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM202 286.1c.877-2.688 1.74-5.398 2.582-8.145c1.434-5.762 7.488-31.54 7.488-52.47C212.1 207 197.1 192 178.6 192C160.1 192 145.1 207 145.1 225.5c0 .2969 .1641 28.81 13.85 62.3c-7.035 19.36-15.57 38.8-25.41 57.93c-21.49 10.11-39.24 22.23-52.8 36.07c-6.234 6.438-9.367 14.74-9.367 24.72c0 18.45 15.01 33.46 33.46 33.46c10.8 0 20.98-5.227 27.22-13.98c7.322-10.28 18.38-26.9 30.47-48.95c15.8-6.352 33.88-11.72 53.88-16c13.55 9.578 28.9 17.29 45.71 22.95c4.527 1.551 9.402 2.348 14.43 2.348c20.26 0 36.13-16.19 36.13-36.86c0-20.33-16.54-36.87-36.87-36.87h-3.705c-2.727 .125-20.51 1.141-45.37 5.367C216.9 308.9 208.6 298.3 202 286.1zM110.2 410.4c-3.273 4.688-12.03 2.777-12.03-5.312c0-1.754 .6289-3.43 1.729-4.555c9.02-9.219 19.94-17.05 31.85-23.72C122.3 393.1 114.3 404.7 110.2 410.4zM178.6 218.8c3.693 0 6.703 3.008 6.703 6.703c0 15.21-4.109 34.84-5.746 42.1C172.1 245 171.9 227.2 171.9 225.5C171.9 221.8 174.9 218.8 178.6 218.8zM162.3 348.3c6.611-13.48 13.22-28.46 19.38-44.7c6.389 10.92 14.56 21.86 24.96 31.97C192.6 338.8 177.4 342.9 162.3 348.3zM272.4 339.5h3.352c5.539 0 10.05 4.5 10.05 10.79c0 5.129-4.176 9.32-9.32 9.32c-2.029 0-4.059-.3164-5.852-.9414c-12.33-4.137-23.11-9.32-32.54-15.19C258.3 340.3 272.1 339.5 272.4 339.5z"/>
</symbol>
<symbol id="icon-file" fill="currentColor" stroke="none" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M365.3 93.38l-74.63-74.64C278.6 6.743 262.3 0 245.4 0L64-.0001c-35.35 0-64 28.65-64 64l.0065 384c0 35.35 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM320 464H64.02c-8.836 0-15.1-7.163-16-15.1L48 64.13c-.0004-8.837 7.163-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1v288C336 456.8 328.8 464 320 464z"/>
</symbol>
<symbol id="icon-sigma" fill="currentColor" stroke="none" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M335.1 136l.0005-56H83.59l165 158.7C253.3 243.2 255.1 249.5 255.1 256s-2.656 12.78-7.375 17.31L83.59 432h252.4l-.0005-56c0-13.25 10.75-24 24-24C373.2 352 384 362.8 384 376v80c0 13.25-10.75 24-24 24H23.99c-9.782 0-18.59-5.938-22.25-15.03s-1.438-19.47 5.625-26.28L197.4 256L7.364 73.31C.3015 66.5-1.917 56.13 1.739 47.03S14.21 32 23.99 32h336C373.2 32 384 42.75 384 56v80C384 149.3 373.2 160 359.1 160C346.7 160 335.1 149.3 335.1 136z"/>
</symbol>
<symbol id="icon-percent" fill="currentColor" stroke="none" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M376.1 71.03c-9.375-9.375-24.56-9.375-33.94 0l-336 336c-9.375 9.375-9.375 24.56 0 33.94C11.72 445.7 17.84 448 24 448s12.28-2.344 16.97-7.031l336-336C386.3 95.59 386.3 80.41 376.1 71.03zM64 176c26.51 0 48-21.49 48-48S90.51 80 64 80C37.49 80 16 101.5 16 128S37.49 176 64 176zM320 336c-26.51 0-48 21.49-48 48s21.49 48 48 48c26.51 0 48-21.49 48-48S346.5 336 320 336z"/>
</symbol>
<symbol id="icon-eye" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</symbol>
<symbol id="icon-profile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" />
</symbol>
<symbol id="icon-chart" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</symbol>
<symbol id="icon-info" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</symbol>
<symbol id="icon-star" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</symbol>
<symbol id="icon-cancel" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</symbol>
<symbol id="icon-bell" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</symbol>
<symbol id="icon-folder" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="icon-power" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
</symbol>
<symbol id="icon-no-symbol" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</symbol>
<symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M18 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8" />
<path d="M16 5l1.5 7l4.5 0" />
<path d="M2 10l15 0" />
<path d="M7 5l0 5" />
<path d="M12 5l0 5" />
</symbol>
<symbol id="icon-ski" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M380.7 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM2.7 268.9c6.1-11.8 20.6-16.3 32.4-10.2L239.8 365l49.6-74.5-82.7-87.9c-9.5-10.1-14.4-22.4-15.1-34.9l87.7 42 49.4 52.5c12.8 13.6 14.5 34.1 4.2 49.6l-50.2 75.4 135.8 70.5c13.6 7.1 29.8 7.2 43.6 .3l15.2-7.6c11.9-5.9 26.3-1.1 32.2 10.7s1.1 26.3-10.7 32.2l-15.2 7.6c-27.5 13.7-59.9 13.5-87.2-.7L12.9 301.3C1.2 295.2-3.4 280.7 2.7 268.9zM118.9 65.6L137 74.2l8.7-17.4c4-7.9 13.6-11.1 21.5-7.2s11.1 13.6 7.2 21.5l-8.5 16.9 55.5 26.6c1.7-.9 3.5-1.7 5.4-2.5l80.9-32.4c32.4-13 68.6 6.5 75.6 40.7l12.3 59.7 62.9 30.1c12 5.7 17 20 11.3 32s-20 17-32 11.3l-54.2-25.9c-1-.3-1.9-.6-2.8-1l-229.1-110-9.2 18.4c-4 7.9-13.6 11.1-21.5 7.2s-11.1-13.6-7.2-21.5l9-18-17.6-8.4c-8-3.8-11.3-13.4-7.5-21.3s13.4-11.3 21.3-7.5zm217.3 64.7c-1-4.9-6.2-7.7-10.8-5.8l-45.7 18.3 65.5 31.5-9-43.9z"/>
</symbol>
<symbol id="icon-snowboard" fill="currentColor" stroke="nonw" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M204.3 3c11.6-6.4 26.2-2.3 32.6 9.3l31.9 57.4c1.3 2.3 3.6 3.8 6.2 4.1l33.8 3.4c24.1 2.4 46.9 12 65.4 27.6L503.5 213.6c10.1 8.5 11.4 23.7 2.9 33.8s-23.7 11.4-33.8 2.9l-68.2-57.5-82.3 43.9 24.7 18.9c24.9 19 34.6 51.9 24.1 81.4l-28.2 79c-1.4 3.8-3.6 7-6.3 9.6l93.3 35.7c4.6 1.8 9.4 2.6 14.3 2.6H472c13.3 0 24 10.7 24 24s-10.7 24-24 24H443.8c-10.8 0-21.4-2-31.5-5.8L60.1 371.3c-11.5-4.4-22-11.2-30.8-20L7 329c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l22.4 22.4c4 4 8.7 7.1 14 9.1l19.4 7.4c-2.9-11.4 2.9-23.4 14-28.1L192 272l0-68.2c0-21.2 12-40.6 31-50.1L284.4 123l-14.1-1.4c-18.3-1.8-34.5-12.5-43.4-28.5L195 35.7c-6.4-11.6-2.3-26.2 9.3-32.6zm91.8 407.2c-.3-3.4 .1-6.9 1.3-10.3l28.2-79c3.5-9.8 .3-20.8-8-27.1L240 234.4l0 42.9c0 16.1-9.7 30.7-24.6 36.9L134 348.2l162.1 62.1zM259.6 189l20.5 15.7 84.6-45.1-17.2-14.5L259.6 189zM384 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0z"/>
</symbol>
<symbol id="icon-hourglass" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
</symbol>
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<g fill="none" fill-rule="evenodd" stroke-width="2">
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="0s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="0s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="-0.9s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="-0.9s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 31 KiB

+16
View File
@@ -40,3 +40,19 @@
.htmx-request.htmx-indicator{ .htmx-request.htmx-indicator{
@apply visible; @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%);
}
+1 -2
View File
@@ -1,7 +1,6 @@
@import "components/typography.css"; @import "components/typography.css";
@import "components/forms.css"; @import "components/forms.css";
@import "components/button.css"; @import "components/button.css";
@import "components/menu.css";
@import "components/tooltip.css"; @import "components/tooltip.css";
@import "components/table-responsive.css";
@import "components/toast.css"; @import "components/toast.css";
@import "components/pagination.css";
+10 -23
View File
@@ -1,7 +1,8 @@
.button { .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 uppercase leading-none text-center;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light; @apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light;
@apply rounded-md;
} }
.button--small { .button--small {
@@ -12,38 +13,24 @@
@apply block w-full; @apply block w-full;
} }
.bg-button { .button--secondary {
@apply bg-none bg-primary-dark text-white; @apply bg-none bg-primary-light text-white;
@apply hover:bg-badge-gradient; @apply hover:bg-primary-light/80;
} }
.button--primary {
.bg-button--active, @apply bg-pink text-white;
.bg-button--secondary { @apply hover:bg-none hover:bg-pink/80;
@apply bg-badge-gradient text-white;
@apply hover:bg-none hover:bg-pink;
} }
.bg-button--muted { .button--muted {
@apply bg-zinc-300 hover:bg-zinc-300; @apply bg-zinc-300 hover:bg-zinc-300;
} }
.bg-button--dark { .button--dark {
@apply bg-primary-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] { .button[disabled] {
@apply cursor-not-allowed; @apply cursor-not-allowed;
} }
+7 -1
View File
@@ -4,7 +4,13 @@ label.required:after {
} }
.form-field { .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 { .form-field--has-error {
-11
View File
@@ -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;
}
+60
View File
@@ -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%);
}
@@ -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;
}
-1
View File
@@ -14,7 +14,6 @@
"doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"knplabs/knp-menu-bundle": "^3.4",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0", "league/oauth2-server-bundle": "^1.0",
Generated
+1 -142
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "44832f4adf896d77d2fd6eec915e7739", "content-hash": "cdf22f45562d34493b3d37f46bea70bc",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1454,147 +1454,6 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "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": "[email protected]"
},
{
"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": "[email protected]"
},
{
"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", "name": "lcobucci/clock",
"version": "3.3.1", "version": "3.3.1",
-1
View File
@@ -13,7 +13,6 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true], League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true], Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
-8
View File
@@ -45,14 +45,6 @@ services:
arguments: arguments:
$intlExtension: '@twig.extension.intl' $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: App\Command\GenerateKeysCommand:
arguments: arguments:
$path: '%path_to_keys%' $path: '%path_to_keys%'
-20
View File
@@ -7,7 +7,6 @@
"name": "myep-next", "name": "myep-next",
"license": "WTFPL", "license": "WTFPL",
"dependencies": { "dependencies": {
"@iframe-resizer/child": "^5.3.2",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0" "toastify-js": "^1.12.0"
}, },
@@ -1638,19 +1637,6 @@
"@hotwired/stimulus": ">= 3.0" "@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": { "node_modules/@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -2750,12 +2736,6 @@
"node": ">= 4.0.0" "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": { "node_modules/autoprefixer": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
-1
View File
@@ -31,7 +31,6 @@
"cy:open": "cypress open" "cy:open": "cypress open"
}, },
"dependencies": { "dependencies": {
"@iframe-resizer/child": "^5.3.2",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0" "toastify-js": "^1.12.0"
} }
@@ -68,17 +68,30 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant); $participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index; $participantData->index = $index;
// First participant (applicant): copy address from applicant if participant address is empty // First participant (applicant): copy data from applicant if participant data is empty
// BPN API may return full address only in <anmelder> but minimal/empty address in <teilnehmer id="1"> // BPN API may return full data only in <anmelder> but minimal/empty data in <teilnehmer id="1">
// Only copy if first participant has no street (indicating empty/incomplete address) if (0 === $index) {
// This allows applicant and first participant to be different people with different addresses // Copy address if first participant has no street (indicating empty/incomplete address)
if (0 === $index && null !== $booking->applicant->address) { // This allows applicant and first participant to be different people with different addresses
$isEmpty = null === $participantData->address if (null !== $booking->applicant->address) {
|| null === $participantData->address->street $isEmpty = null === $participantData->address
|| '' === trim($participantData->address->street); || null === $participantData->address->street
|| '' === trim($participantData->address->street);
if ($isEmpty) { if ($isEmpty) {
$participantData->address = clone $booking->applicant->address; $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;
} }
} }
@@ -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) // Add wishes (room remarks and license plate)
if (null !== $participant->remarksRoom || null !== $participant->licensePlate) { if (null !== $participant->remarksRoom || null !== $participant->licensePlate) {
$participantData['wünsche'] = []; $participantData['wünsche'] = [];
@@ -20,7 +20,7 @@ class PersonalDataSynchronizer
/** /**
* Updates participant personal data from form input. * 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. * Updates all personal data fields and communication information.
* *
* IMPORTANT: The applicant's address must never be modified. This method updates * 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 public function updateParticipantPersonalData(array $participants, Booking $bookingData): void
{ {
foreach ($participants as $participant) { foreach ($participants as $participant) {
if ('F' !== $participant->status) { // Skip canceled participants (status 'S')
if ('S' === $participant->status) {
continue; continue;
} }
+6 -1
View File
@@ -27,7 +27,7 @@ class PersonalData
public ?string $name = null; public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
public string $firstName = ''; public ?string $firstName = '';
public ?string $salutation = null; public ?string $salutation = null;
public ?string $title = null; public ?string $title = null;
@@ -55,6 +55,11 @@ class PersonalData
$this->communication = new Communication(); $this->communication = new Communication();
} }
public function getFullName(): string
{
return sprintf('%s %s', $this->firstName, $this->name);
}
/** /**
* Converts the personal data to API payload format. * Converts the personal data to API payload format.
* *
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Controller\Account;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
#[Route('/account', name: 'app_account')]
#[IsGranted('ROLE_USER')]
public function index(): Response
{
return $this->render('account/index.html.twig');
}
}
@@ -1,6 +1,6 @@
<?php <?php
namespace App\Controller; namespace App\Controller\Account;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
@@ -98,7 +98,7 @@ class PersonalDataController extends AbstractController
return $this->redirectToRoute('app_personal_data'); return $this->redirectToRoute('app_personal_data');
} }
return $this->render('personal_data/index.html.twig', [ return $this->render('account/personal_data.html.twig', [
'personalData' => $personalData, 'personalData' => $personalData,
'personalDataForm' => $personalDataForm->createView(), 'personalDataForm' => $personalDataForm->createView(),
]); ]);
@@ -9,6 +9,7 @@ use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException; use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Htmx\HxTrait;
use App\Service\BookingService; use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -24,6 +25,8 @@ use Symfony\Component\Routing\Attribute\Route;
*/ */
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
use HxTrait;
public function __construct( public function __construct(
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
private readonly AgencyLoader $agencyLoader, private readonly AgencyLoader $agencyLoader,
@@ -113,18 +116,22 @@ class IndexController extends AbstractController
#[Route('/bookings/cancel', name: 'app_booking_cancel')] #[Route('/bookings/cancel', name: 'app_booking_cancel')]
public function cancel(Request $request): Response public function cancel(Request $request): Response
{ {
// Clear the booking session if (Request::METHOD_POST === $request->getMethod()) {
$this->bookingService->clearBookingSession($request); // Clear the booking session
$this->bookingService->clearBookingSession($request);
// Clear the security target path to prevent redirect loop after login // 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 // Without this, logging in after cancel would redirect back to a stale booking URL
$request->getSession()->remove('_security.main.target_path'); $request->getSession()->remove('_security.main.target_path');
// Add a flash message to inform the user // Add a flash message to inform the user
$this->addFlash('info', 'Buchung abgebrochen.'); $this->addFlash('info', 'Buchung abgebrochen.');
// Redirect to login page // Redirect to login page
return $this->redirectToRoute('app_login'); return $this->hxRedirect($request, $this->generateUrl('app_login'));
}
return $this->render('booking/modal_cancel.html.twig');
} }
/** /**
+5 -1
View File
@@ -17,7 +17,8 @@ class SecurityController extends AbstractController
public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response
{ {
// Check if this is a booking flow (BookingDto exists in session) // 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 authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $isBookingFlow) { if (null !== $this->getUser() && true === $isBookingFlow) {
@@ -52,6 +53,9 @@ class SecurityController extends AbstractController
return $this->render($template, [ return $this->render($template, [
'last_username' => $lastUsername, 'last_username' => $lastUsername,
'error' => $error, 'error' => $error,
'travel_title' => $bookingDto?->travel->label,
'travel_date_from' => $bookingDto?->travel->dateFrom,
'travel_date_to' => $bookingDto?->travel->dateTo,
]); ]);
} }
+5 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model; namespace App\Form\Model;
use App\BusProNet\Model\BankAccount;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
@@ -11,11 +12,11 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class BankAccountDto 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.')] #[Assert\Iban(message: 'Die eingegebene IBAN ist ungültig.')]
public ?string $iban = null; public ?string $iban = null;
#[Assert\NotBlank(message: 'Bitte geben Sie den Kontoinhaber ein.')] #[Assert\NotBlank(message: 'Bitte gib den Kontoinhaber ein.')]
#[Assert\Length( #[Assert\Length(
min: 2, min: 2,
max: 70, max: 70,
@@ -30,10 +31,10 @@ class BankAccountDto
)] )]
public ?string $bankName = null; 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 bool $sepaMandateAccepted = false;
public static function fromBankAccount(\App\BusProNet\Model\BankAccount $bankAccount): static public static function fromBankAccount(BankAccount $bankAccount): static
{ {
$instance = new static(); $instance = new static();
$instance->iban = $bankAccount->iban; $instance->iban = $bankAccount->iban;
+5 -5
View File
@@ -41,7 +41,7 @@ class BookingDto
#[Assert\Choice( #[Assert\Choice(
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT], 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; public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
@@ -255,7 +255,7 @@ class BookingDto
} }
if (null === $this->bankAccount) { if (null === $this->bankAccount) {
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.') $context->buildViolation('Bitte gib deine Bankverbindung an.')
->atPath('bankAccount') ->atPath('bankAccount')
->addViolation(); ->addViolation();
@@ -263,19 +263,19 @@ class BookingDto
} }
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) { 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') ->atPath('bankAccount.iban')
->addViolation(); ->addViolation();
} }
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) { 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') ->atPath('bankAccount.accountHolder')
->addViolation(); ->addViolation();
} }
if (false === $this->bankAccount->sepaMandateAccepted) { if (false === $this->bankAccount->sepaMandateAccepted) {
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.') $context->buildViolation('Bitte akzeptiere das SEPA-Mandat.')
->atPath('bankAccount.sepaMandateAccepted') ->atPath('bankAccount.sepaMandateAccepted')
->addViolation(); ->addViolation();
} }
+4 -2
View File
@@ -15,7 +15,8 @@ class BookingSummaryDto
/** /**
* @param array<int, RoomSelectionDto> $selectedRooms Selected room DTOs from booking * @param array<int, RoomSelectionDto> $selectedRooms Selected room DTOs from booking
* @param int $participantCount Total participant count from room capacity * @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<array{room: mixed, count: int}> $groupedSelectedRooms Rooms grouped by participant assignments * @param array<array{room: mixed, count: int}> $groupedSelectedRooms Rooms grouped by participant assignments
* @param array<int, int> $assignmentCounts Room ID to participant count mapping * @param array<int, int> $assignmentCounts Room ID to participant count mapping
* @param array $pricingData Detailed pricing breakdown * @param array $pricingData Detailed pricing breakdown
@@ -24,7 +25,8 @@ class BookingSummaryDto
public function __construct( public function __construct(
public readonly array $selectedRooms, public readonly array $selectedRooms,
public readonly int $participantCount, public readonly int $participantCount,
public readonly string $totalPrice, public readonly float $totalPrice,
public readonly float $payableAmount,
public readonly array $groupedSelectedRooms, public readonly array $groupedSelectedRooms,
public readonly array $assignmentCounts, public readonly array $assignmentCounts,
public readonly array $pricingData, public readonly array $pricingData,
-1
View File
@@ -35,7 +35,6 @@ class PersonalDataType extends AbstractType
->add('email', EmailType::class, [ ->add('email', EmailType::class, [
'label' => 'E-Mail', 'label' => 'E-Mail',
'property_path' => 'communication.email', 'property_path' => 'communication.email',
'sanitize_html' => true,
]) ])
->add('phone', TextType::class, [ ->add('phone', TextType::class, [
'label' => 'Telefon', 'label' => 'Telefon',
+1 -1
View File
@@ -29,10 +29,10 @@ class RegistrationType extends AbstractType
]) ])
->add('name', TextType::class, [ ->add('name', TextType::class, [
'label' => 'Nachname', 'label' => 'Nachname',
'sanitize_html' => true,
]) ])
->add('email', EmailType::class, [ ->add('email', EmailType::class, [
'label' => 'E-Mail', 'label' => 'E-Mail',
'sanitize_html' => true,
]) ])
; ;
} }
+16 -8
View File
@@ -8,6 +8,8 @@ use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class RoomSelectType extends AbstractType class RoomSelectType extends AbstractType
@@ -20,15 +22,8 @@ class RoomSelectType extends AbstractType
$data = $event->getData(); $data = $event->getData();
$form = $event->getForm(); $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, [ $form->add('quantity', StepSelectChoiceType::class, [
'label' => $label, 'label' => false,
'required' => true, 'required' => true,
'min_value' => 0, 'min_value' => 0,
'max_value' => $data->maxQuantity, '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 public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
@@ -167,7 +167,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex $participantIndex
), ),
'choice_value' => 'id', '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) { 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) { if (null === $service) {
return []; return [];
@@ -205,7 +205,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex $participantIndex
), ),
'choice_value' => 'id', '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) { 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) { if (null === $service) {
return []; return [];
@@ -251,7 +251,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex $participantIndex
), ),
'choice_value' => 'id', '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) { 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) { if (null === $service) {
return []; return [];
@@ -294,7 +294,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex $participantIndex
), ),
'choice_value' => 'id', '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) { 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) { if (null === $service) {
return []; return [];
@@ -353,7 +353,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex $participantIndex
), ),
'choice_value' => 'id', '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) { 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) { if (null === $service) {
return []; return [];
@@ -396,7 +396,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$bookingDto, $bookingDto,
$participantIndex $participantIndex
), ),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_label' => fn (Service $service) => $service?->label,
'choice_value' => 'id', 'choice_value' => 'id',
'expanded' => true, 'expanded' => true,
'multiple' => false, 'multiple' => false,
@@ -427,7 +427,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ $this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rückfahrt', 'label' => 'Rückfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), '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', 'choice_value' => 'id',
'expanded' => true, 'expanded' => true,
'multiple' => false, 'multiple' => false,
@@ -653,41 +653,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); 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 private function formatPickupLabelWithPrice(?Pickup $pickup): string
{ {
if (null === $pickup) { if (null === $pickup) {
@@ -935,11 +900,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/ */
private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array 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.) // Separate PKW/CAR from other services (BUS, etc.)
$pkwServices = []; $pkwServices = [];
$otherServices = []; $otherServices = [];
-49
View File
@@ -1,49 +0,0 @@
<?php
namespace App\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
class MenuBuilder
{
public function __construct(
private readonly FactoryInterface $factory,
) {
}
public function createMainMenu(): ItemInterface
{
$menu = $this->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;
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return new RedirectResponse($targetPath); 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 private function collectRoles(CrmAttributes $crmAttributes): array
+30 -4
View File
@@ -69,10 +69,14 @@ class BookingSummaryDataService
// Calculate participant count from room capacity (source of truth) // Calculate participant count from room capacity (source of truth)
$participantCount = $this->calculateParticipantCountFromRooms($bookingDto); $participantCount = $this->calculateParticipantCountFromRooms($bookingDto);
// Calculate payable amount after voucher deductions
$payableAmount = $this->calculatePayableAmount($bookingDto, $pricingData['grandTotal'], $participantPrices);
return new BookingSummaryDto( return new BookingSummaryDto(
selectedRooms: $selectedRooms, selectedRooms: $selectedRooms,
participantCount: $participantCount, participantCount: $participantCount,
totalPrice: number_format($totalPrice, 2, ',', '.').' €', totalPrice: $pricingData['grandTotal'],
payableAmount: $payableAmount,
groupedSelectedRooms: $groupedSelectedRooms, groupedSelectedRooms: $groupedSelectedRooms,
assignmentCounts: $roomCounts, assignmentCounts: $roomCounts,
pricingData: $pricingData, 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 * @param array<int, float> $participantPrices Prices per participant for percentage voucher calculation
* multiplying each selected room's quantity by its maximum capacity (maxPax). */
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 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; $totalCapacity = 0;
$availableRooms = $bookingDto->travel->getAvailableRooms(); $availableRooms = $bookingDto->travel->getAvailableRooms();
+1 -2
View File
@@ -24,12 +24,11 @@ class AppExtension extends AbstractExtension
public function getFunctions(): array public function getFunctions(): array
{ {
return [ return [
new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]),
new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']), new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']),
new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]), new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]),
new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']), new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']),
new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']), new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']),
new TwigFunction('gravatar_url', [AppRuntime::class, 'getGravatarUrl']), new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']),
]; ];
} }
} }
+24 -15
View File
@@ -52,14 +52,6 @@ class AppRuntime implements RuntimeExtensionInterface
return $this->intlExtension->formatCurrency($amount, 'EUR'); 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 public function mapStatus(string $status): string
{ {
$status = strtoupper($status); $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 FormView $form The form view to check
* @param int $size The size of the avatar in pixels (default: 80)
* *
* @return string The Gravatar URL * @return array<string> 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;
} }
/** /**
-3
View File
@@ -47,9 +47,6 @@
".php-cs-fixer.dist.php" ".php-cs-fixer.dist.php"
] ]
}, },
"knplabs/knp-menu-bundle": {
"version": "v3.4.2"
},
"league/flysystem-bundle": { "league/flysystem-bundle": {
"version": "3.4", "version": "3.4",
"recipe": { "recipe": {
+31 -31
View File
@@ -1,36 +1,36 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
'./templates/**/*.twig', './templates/**/*.twig',
'./src/**/*.php', './src/**/*.php',
], ],
safelist: ['rotate-90'], safelist: ['rotate-90'],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: '2rem', 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: { plugins: [
fontFamily: { require('@tailwindcss/forms'),
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'),
],
} }
+10 -10
View File
@@ -1,32 +1,32 @@
{% if modal is not defined %} {% if modal is not defined %}
{% set modal = false %} {% set modal = false %}
{% endif %} {% endif %}
<div class="{{ html_classes('rounded-md mb-4', { 'py-4': modal == true, 'p-4': modal == false, 'bg-red-50': level == 'error', 'bg-green-50': level == 'success', 'bg-blue-50': level == 'info' }) }}"> <div class="{{ html_classes('rounded-md mb-4', { 'p-4': modal == false, 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-yellow-500': level == 'warning', 'bg-primary-light': level == 'info' }) }}">
<div class="flex"> <div class="flex items-center">
<div class="shrink-0"> <div class="shrink-0 text-white">
{% if level == 'error' %} {% if level == 'error' or level == 'warning' %}
<svg class="size-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" />
</svg> </svg>
{% elseif level == 'success' %} {% elseif level == 'success' %}
<svg class="size-5 text-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg> </svg>
{% else %} {% else %}
<svg class="size-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
</svg> </svg>
{% endif %} {% endif %}
</div> </div>
<div class="ml-3"> <div class="ml-3">
{% if title is defined %} {% if title is defined %}
<div class="{{ html_classes('text-sm', { 'text-red-700': level == 'error', 'text-green-800': level == 'success', 'text-blue-800': level == 'info', 'mb-2': messages is defined }) }}"> <div class="text-white">
{{ title }} {{ title }}
</div> </div>
{% endif %} {% endif %}
{% if messages is defined %} {% if messages is defined %}
<div class="{{ html_classes('text-sm', { 'text-red-700': level == 'error', 'text-green-800': level == 'success', 'text-blue-800': level == 'info' }) }}"> <div class="text-white">
<ul role="list" class="list-disc space-y-1 pl-4"> <ul role="list" class="space-y-1">
{% for message in messages %} {% for message in messages %}
<li> <li>
{{ message|raw }} {{ message|raw }}
+19
View File
@@ -0,0 +1,19 @@
<div class="flex items-center justify-between">
<a href="https://www.ep-reisen.de" title="zur E&amp;P Website">
<img src="{{ asset('build/images/logo.svg') }}" alt="" class="h-10 my-2 w-auto">
</a>
<div class="flex items-center space-x-2">
{% if is_granted('ROLE_USER') %}
<a href="{{ path('app_logout') }}" title="aus MyE&P abmelden">
<svg class="w-10 h-10 text-primary-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
<a href="{{ path('app_account') }}" title="zu meinem Account">
<svg class="w-10 h-10 text-primary-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="128" cy="120" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M63.8,199.37a72,72,0,0,1,128.4,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% else %}
<a href="{{ path('app_login') }}" title="zu meinem Account">
<svg class="w-10 h-10 text-primary-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="128" cy="120" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M63.8,199.37a72,72,0,0,1,128.4,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% endif %}
</div>
</div>
@@ -1,4 +1,39 @@
<div class="invisible fixed top-0 left-0 inset-0 z-10 backdrop-blur-sm flex items-center justify-center transition-all duration-100" <div class="invisible fixed top-0 left-0 inset-0 z-50 backdrop-blur-sm flex items-center justify-center transition-all duration-100"
{{ stimulus_target('loading', 'indicator') }}> {{ stimulus_target('loading', 'indicator') }}>
{% include '_partials/_spinner.html.twig' with { 'class': 'text-primary-light w-32 h-32 lg:w-48 lg:h-48'} %} <svg class="text-primary-light w-32 h-32 lg:w-48 lg:h-48" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<g fill="none" fill-rule="evenodd" stroke-width="2">
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="0s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="0s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="-0.9s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="-0.9s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
</g>
</svg>
</div> </div>
+8 -10
View File
@@ -1,21 +1,19 @@
<div class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}> <div class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
<div class="absolute inset-0 w-full h-full bg-black/80" {{ stimulus_action('modal', 'close', 'click') }}></div> <div class="absolute inset-0 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-2xl"> <div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-2xl">
<div class="{{ html_classes('relative py-8 rounded-md', { 'bg-red-50': level == 'error', 'bg-green-50': level == 'success', 'bg-blue-50': level == 'info' }) }}"> <div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-primary-light': level == 'info' }) }}">
<div class="{{ html_classes('flex justify-between pb-4 px-8', { 'text-red-800': level == 'error', 'text-green-800': level == 'success', 'text-blue-800': level == 'info' }) }}"> <div class="flex justify-between pb-4 px-4">
<div class="text-2xl font-medium"> <div class="text-2xl font-medium text-white">
{% block title %}{% endblock %} {% block title %}{% endblock %}
</div> </div>
<button type="button" <button type="button"
class="inline-block" class="inline-block bg-gray-200 rounded-md p-1"
{{ stimulus_action('modal', 'close') }}> {{ stimulus_action('modal', 'close') }}>
{{ icon('close', 'w-6 h-6')}} <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button> </button>
</div> </div>
<div class="max-h-96 overflow-y-auto"> <div class="px-4 max-h-96 overflow-y-auto">
<div class="{{ html_classes('px-8', { 'text-red-700': level == 'error', 'text-green-700': level == 'success', 'text-blue-700': level == 'info' }) }}"> {% block content %}{% endblock %}
{% block content %}{% endblock %}
</div>
</div> </div>
</div> </div>
</div> </div>
-37
View File
@@ -1,37 +0,0 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg class="{{ class }}" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<g fill="none" fill-rule="evenodd" stroke-width="2">
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="0s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="0s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
<circle cx="22" cy="22" r="1">
<animate attributeName="r"
begin="-0.9s" dur="1.8s"
values="1; 20"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.165, 0.84, 0.44, 1"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="-0.9s" dur="1.8s"
values="1; 0"
calcMode="spline"
keyTimes="0; 1"
keySplines="0.3, 0.61, 0.355, 1"
repeatCount="indefinite" />
</circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,16 @@
{# Validation error alert box #}
{# Parameters:
- form: The form to check for validation errors
- message: Optional custom error message
- showLabels: Whether to show the list of invalid field labels (default: true)
#}
{% macro validation_alert(form, message, showLabels = true) %}
{% if not form.vars.valid %}
{% set errorLabels = showLabels ? collect_invalid_field_labels(form) : [] %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: message|default('Du hast nicht alle Pflichtfelder ausgefüllt oder einzelne Angaben sind ungültig:'),
messages: [errorLabels | join(', ')]
} %}
{% endif %}
{% endmacro %}
+33
View File
@@ -0,0 +1,33 @@
{% extends 'layout.html.twig' %}
{% block content %}
<div class="px-4 lg:px-8 py-8 lg:py-16">
<h1 class="text-white uppercase pb-4">
Mein<br>Account
</h1>
</div>
<div class="p-4 ld:p-8">
<nav>
<ul class="divide-y divide-primary-bg/40">
<li class="py-4">
<a href="{{ path('app_bookings') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="128" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="64" x2="216" y2="64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="192" x2="216" y2="192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 64 56 80 88 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 128 56 144 88 112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 192 56 208 88 176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Buchungen</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_personal_data') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M160,224c3.67-13.8,16.6-24,32-24s28.33,10.2,32,24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M120,208H40a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6l27.74,20.8a8,8,0,0,0,4.8,1.6H216a8,8,0,0,1,8,8v32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Daten</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_logout') }}" class="flex items-center space-x-2 group">
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">abmelden</span>
</a>
</li>
</ul>
</nav>
</div>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends 'layout.html.twig' %}
{% block content %}
<div class="px-4 lg:px-8 py-8 lg:py-16">
<h1 class="text-white uppercase pb-4">
Meine<br>Daten
</h1>
</div>
<div class="p-4 ld:p-8">
<div class="divide-y divide-primary-bg/40">
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
<div class="text-white">
<strong>Name</strong>: {{ personalData.fullName }}
<br>
<strong>Gender</strong>: {{ personalData.gender|map_gender }}
<br>
<strong>Geburtsdatum</strong>: {{ personalData.dateOfBirth|date('d.m.Y') }}
<br>
<strong>Nationalität</strong>: {{ personalData.nationality|map_nationality|default('-') }}
</div>
<div id="newsletter">
<p class="pb-4 text-white">
Du bist aktuell {% if not personalData.communication.newsletter %}<strong>nicht</strong> {% endif%} zum Newsletter
angemeldet.
</p>
<button type="button"
class="relative button button--primary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-target="#newsletter"
hx-select="#newsletter"
hx-swap="outerHTML">
{% if personalData.communication.newsletter %}jetzt abmelden{% else %}jetzt anmelden{% endif %}
</button>
</div>
</div>
<div class="pt-8">
<h2 class="text-white uppercase text-xl">
Kontaktdaten
</h2>
{% include '_partials/_flashes.html.twig' %}
{{ form_start(personalDataForm, { 'attr': { 'data-action': 'loading#toggle' } }) }}
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
<div>
{{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.city, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.country, { 'label_attr': { 'class': 'text-white' } }) }}
</div>
<div>
{{ form_row(personalDataForm.email, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.phone, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.mobile, { 'label_attr': { 'class': 'text-white' } }) }}
</div>
</div>
<div class="flex justify-between">
<a href="{{ path('app_account') }}" class="button button--secondary">
zurück
</a>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
{{ form_rest(personalDataForm) }}
{{ form_end(personalDataForm) }}
</div>
</div>
</div>
{% endblock %}
+1
View File
@@ -2,6 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MyE&P.next{% endblock %}</title> <title>{% block title %}MyE&P.next{% endblock %}</title>
{% block stylesheets %} {% block stylesheets %}
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
-5
View File
@@ -1,5 +0,0 @@
<div class="mt-8 pt-4 border-t border-gray-200 text-center">
<a href="{{ path('app_booking_cancel') }}" class="text-sm text-gray-600 hover:text-gray-900 hover:underline">
Buchung abbrechen und zur Startseite zurückkehren
</a>
</div>
+75
View File
@@ -0,0 +1,75 @@
{%- block form_row -%}
{%- set row_attr = row_attr|merge({ 'class': (row_attr.class|default('') ~ ' mb-4')|trim }) -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
{%- if form.vars.expanded is defined and form.vars.expanded -%}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.vars.errors | length > 0 }) }}">
{{ label }}
{%- if required -%}<span>*</span>{%- endif -%}
</legend>
{{- form_widget(form, widget_attr) -}}
</fieldset>
{{- form_errors(form) -}}
{{- form_help(form) -}}
</div>
{%- else -%}
<div{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_errors(form) -}}
{{- form_help(form) -}}
</div>
{%- endif -%}
{%- endblock form_row -%}
{%- block choice_widget_expanded -%}
<table class="w-full table-fixed border-collapse">
{%- for child in form %}
{% set choiceData = form.vars.choices[loop.index0].data %}
{% set description = child.vars.attr['data-description']|default(null) %}
{% set isReadonly = child.vars.attr.readonly is defined %}
{%- set child_attr = {} -%}
{%- if attr['hx-trigger'] is defined -%}
{%- set child_attr = {
'hx-trigger': attr['hx-trigger'],
'hx-post': attr['hx-post'],
'hx-swap': attr['hx-swap']
} -%}
{%- endif -%}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{- child.vars.label -}}</div>
{%- if description is not null -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">{{ description|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
{% if choiceData %}
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if choiceData.price %}
{{ choiceData.price | format_currency('EUR') }}
{% endif %}
</td>
{% endif %}
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{- form_widget(child, { 'attr': child_attr }) -}}
</div>
</div>
</td>
</tr>
{% endfor -%}
</table>
{%- endblock choice_widget_expanded -%}
+30
View File
@@ -0,0 +1,30 @@
{% set current_step = current_step ?? 1 %}
{% set step_routes = {
1: 'app_booking_create_step_1',
2: 'app_booking_create_step_2',
3: 'app_booking_create_step_3',
4: 'app_booking_create_step_4'
} %}
<nav class="pagination">
{% for step in 1..4 %}
<div class="pagination-item">
{% if step < current_step %}
<a href="{{ path(step_routes[step]) }}"
class="pagination-item__border" {{ stimulus_action('loading', 'toggle') }}>
<span class="pagination-item__inner bg-primary-bg text-gray-800">
{{ step }}
</span>
</a>
{% else %}
<span class="pagination-item__border">
<span class="{{ html_classes('pagination-item__inner', {
'bg-primary-light text-white': step == current_step,
'bg-primary-bg text-gray-800': step != current_step
}) }}">
{{ step }}
</span>
</span>
{% endif %}
</div>
{% endfor %}
</nav>
+32 -54
View File
@@ -4,66 +4,44 @@
{% set errorMessages = cardData.errorMessages|default([]) %} {% set errorMessages = cardData.errorMessages|default([]) %}
{% set mode = mode|default('create') %} {% set mode = mode|default('create') %}
<div id="participant-card-{{ index }}" <div class="py-4" id="participant-card-{{ index }}">
class="{{ html_classes('border rounded p-4', { 'border-gray-400 bg-gray-50': isCanceled, 'border-red-700': not isValid }) }}"> <div class="flex items-start space-x-4">
<div class="flex justify-between items-start"> <div class="{{ html_classes('w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full text-white text-2xl font-bold', { 'bg-primary-dark': isValid, 'bg-red-500': not isValid }) }}">
<div class="flex-1"> {{ participantNumber }}
<div class="flex items-start gap-3">
<div class="flex-shrink-0"
{{ stimulus_controller('gravatar', {
url: gravatar_url(cardData.email),
alt: cardData.name
}, {
image: 'w-12 h-12 rounded-full'
}) }}
{{ qa_attribute('participant-avatar', index) }}>
{{ icon('user', 'w-12 h-12 text-gray-400') }}
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold {{ isCanceled ? 'text-gray-600' : '' }}" {{ qa_attribute('participant-name', index) }}>
{{ cardData.name }}
</h3>
{% if isCanceled %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
storniert
</span>
{% endif %}
{% if not isValid %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-700 text-white" {{ qa_attribute('participant-invalid', index) }}>
unvollständige oder fehlerhafte Daten
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
</div>
</div>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex-1 leading-5">
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span> <div class="font-semibold mb-1">
{{ cardData.name }}
</div>
{% if isCanceled %} {% if isCanceled %}
<button type="button" <div class="text-xs font-medium text-gray-700" {{ qa_attribute('participant-canceled', index) }}>
class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed" storniert
disabled </div>
title="Stornierte Teilnehmer können nicht bearbeitet werden"> {% elseif not isValid %}
Bearbeiten <div class="text-xs font-medium text-red-500" {{ qa_attribute('participant-invalid', index) }}>
</button> unvollständige/fehlerhafte Daten
</div>
{% else %} {% else %}
<div class="text-gray-600" {{ qa_attribute('participant-room-name', index) }}>
{{ cardData.roomName }}
<br>
{{ cardData.price }}
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center">
{% if not isCanceled %}
{% if mode == 'edit' %} {% if mode == 'edit' %}
<a href="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}" {% set url = path('app_booking_edit_participant', {id: bookingId, index: index}) %}
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</a>
{% else %} {% else %}
<a href="{{ path('app_booking_create_step_2_participant', {index: index}) }}" {% set url = path('app_booking_create_step_2_participant', {index: index}) %}
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</a>
{% endif %} {% endif %}
<a href="{{ url }}"
class="button button--small button--primary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+491 -358
View File
@@ -1,5 +1,3 @@
{% import _self as macros %}
{# Macro to render a form field or static value based on field state #} {# Macro to render a form field or static value based on field state #}
{% macro field_or_static(form, fieldName, label, staticValue, bookingDto, participantIndex, options = {}) %} {% macro field_or_static(form, fieldName, label, staticValue, bookingDto, participantIndex, options = {}) %}
{% if form[fieldName] is defined %} {% if form[fieldName] is defined %}
@@ -27,384 +25,519 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{# Specialized macro for checkbox fields (like rental insurance) that need manual fieldset wrapping #} {# Macro to render a service choice table row with optional price and tooltip #}
{% macro checkbox_field(form, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} {% macro service_choice_row(child, price, tooltipText, tooltipLabel, widgetAttr = {}) %}
{% if form[fieldName] is defined %} {% set isReadonly = child.vars.attr.readonly is defined %}
<fieldset class="mb-1"> <tr>
<legend class="font-semibold mb-1">{{ label }}</legend> <td class="border border-primary-bg p-2 align-top">
{{ form_row(form[fieldName], options) }} <div>{{ child.vars.label }}</div>
</fieldset> {%- if tooltipText is not null -%}
{% else %} <div {{ stimulus_controller('tooltip') }}>
<fieldset class="mb-1"> <button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
<legend class="font-semibold mb-1">{{ label }}</legend> {{ tooltipLabel|default('Details') }}
{% if undefinedLabel %} </button>
<div class="text-sm text-gray-500">{{ undefinedLabel }}</div> <template data-tooltip-target="template">
<div class="text-sm">{{ tooltipText|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %} {% endif %}
</fieldset> </td>
{% endif %} <td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(child, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %}
{# Macro to render a checkbox table row (for trailing checkboxes like rental insurance, parking, bulk insurance) #}
{% macro checkbox_row(checkboxField, label, price, tooltipText, tooltipLabel, widgetAttr = {}) %}
{% set isReadonly = checkboxField.vars.attr.readonly is defined %}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ label }}</div>
{%- if tooltipText is not null -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
{{ tooltipLabel|default('Info') }}
</button>
<template data-tooltip-target="template">
<div class="text-sm">{{ tooltipText|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set fieldTooltip = checkboxField.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if fieldTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': fieldTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(checkboxField, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %}
{# Macro to render insurance choice table row with product info links #}
{% macro insurance_choice_row(child, price, urlsProductInfo, widgetAttr = {}) %}
{% set isReadonly = child.vars.attr.readonly is defined %}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ child.vars.label }}</div>
{%- if urlsProductInfo is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in urlsProductInfo -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ urlsProductInfo|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(child, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %} {% endmacro %}
{# Standalone participant form view (replaces main content area) #} {# Standalone participant form view (replaces main content area) #}
{% block participant_form %} {% block participant_form %}
{% import _self as macros %} {% form_theme form 'booking/_form_theme.html.twig' %}
<div id="participant-form-view"> {% import _self as macros %}
<div class="flex items-center gap-4 mb-6">
<div class="flex-shrink-0" <div class="flex items-center gap-4 pb-4 mb-4 border-b border-primary-bg">
{{ stimulus_controller('gravatar', { <div class="w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
url: gravatar_url(form.vars.data.participant.email ?? ''), {{ participantIndex + 1 }}
alt: participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) </div>
}, { <div class="text-2xl">
image: 'w-16 h-16 rounded-full' {{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
}) }}>
{{ icon('user', 'w-16 h-16 text-gray-400') }}
</div> </div>
<h2 class="text-2xl">{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}</h2>
</div> </div>
{{ form_start(form, { <div id="participant-form" class="space-y-4">
'attr': {
'novalidate': 'novalidate',
'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
<div id="participant-form" class="space-y-4">
{# Personal data section #}
<div class="grid grid-cols-2 gap-4">
{{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName, bookingDto, participantIndex) }}
{{ macros.field_or_static( {% from '_partials/_validation_errors.html.twig' import validation_alert %}
form, {{ validation_alert(form) }}
'dateOfBirth',
'Geburtsdatum',
form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : null,
bookingDto,
participantIndex,
{
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}
) }}
{% set genderLabel = form.vars.data.participant.gender == 'M' ? 'männlich' : (form.vars.data.participant.gender == 'W' ? 'weiblich' : (form.vars.data.participant.gender == 'D' ? 'divers' : null)) %} {# Eligibility checks #}
{{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel, bookingDto, participantIndex) }} {% set participantData = form.vars.data.participant %}
{% set hasDateOfBirth = participantData and participantData.dateOfBirth %}
{% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %}
{{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality|map_nationality, bookingDto, participantIndex) }} {% if not hasDateOfBirth %}
</div> {% include '_partials/_alert.html.twig' with {
level: 'info',
messages: ['Leistungen sind erst nach Angabe des Geburtsdatums buchbar']
} %}
{% elseif not isEligible %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
messages: ['Buchung wegen des Alters von Teilnehmer:in ' ~ (participantIndex + 1) ~ ' nicht möglich']
} %}
{% endif %}
{# Personal data section #}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName, bookingDto, participantIndex) }}
{{ macros.field_or_static(
form,
'dateOfBirth',
'Geburtsdatum',
form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : null,
bookingDto,
participantIndex,
{
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}
) }}
{% set genderLabel = form.vars.data.participant.gender == 'M' ? 'männlich' : (form.vars.data.participant.gender == 'W' ? 'weiblich' : (form.vars.data.participant.gender == 'D' ? 'divers' : null)) %}
{{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality|map_nationality, bookingDto, participantIndex) }}
{# Contact information #} {# Contact information #}
<div class="grid grid-cols-2 gap-4"> {{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email, bookingDto, participantIndex) }} {{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile, bookingDto, participantIndex) }}
</div>
{# Address #}
{% if form.address is defined %}
{# Address field is in form - render as editable form fields #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.address.street) }}
{{ form_row(form.address.postCode) }}
{{ form_row(form.address.city) }}
{{ form_row(form.address.country) }}
</div>
{% elseif is_static_text('address', bookingDto, participantIndex) %}
{# Address excluded from form but should render as static text #}
{# For applicant (index 0), use booking.applicant.address which has full data from BPN #}
{# For other participants, use participants[index].address (may only have country in BPN response) #}
{% set addressSource = (0 == participantIndex and bookingDto.booking) ? bookingDto.booking.applicant : bookingDto.participants[participantIndex] %}
<div>
<label class="font-semibold mb-1 block">Adresse</label>
{% if addressSource.address %}
<div class="text-sm text-gray-600">
{% if addressSource.address.street %}{{ addressSource.address.street }}<br>{% endif %}
{% if addressSource.address.postCode or addressSource.address.city %}
{{ addressSource.address.postCode }} {{ addressSource.address.city }}<br>
{% endif %}
{% if addressSource.address.country %}{{ addressSource.address.country|map_country }}{% endif %}
</div>
{% else %}
<div class="text-sm text-gray-600">-</div>
{% endif %}
</div>
{% endif %}
{# Room assignment - hidden until date of birth is provided, or shown as static text in edit mode #}
{% if form.assignedRoomId is defined or is_static_text('assignedRoomId', bookingDto, participantIndex) %}
<div class="grid grid-cols-2 gap-4">
{% set assignedRoom = bookingDto.travel.getRoomById(form.vars.data.participant.assignedRoomId) %}
{% set roomLabel = assignedRoom ? assignedRoom.label : 'Kein Zimmer zugewiesen' %}
{{ macros.field_or_static(form, 'assignedRoomId', 'Zimmer', roomLabel, bookingDto, participantIndex, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{% endif %}
{# Eligibility checks #}
{% set participantData = form.vars.data.participant %}
{% set hasDateOfBirth = participantData and participantData.dateOfBirth %}
{% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %}
{% if not hasDateOfBirth %}
<div class="my-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-blue-800">
Leistungen sind erst nach Angabe des Geburtsdatums buchbar
</p>
</div>
</div>
{% elseif not isEligible %}
<div class="my-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-red-800">
Buchung wegen des Alters von Teilnehmer:in {{ participantIndex + 1 }} nicht möglich
</p>
</div>
</div>
{% endif %}
{# Service selection #}
{% if isEligible %}
<div class="grid grid-cols-2 gap-4">
{{ macros.service_field(form, 'skiPass', 'Skipass', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'courses', 'Kurse', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'additionalServices', 'Zusatzleistungen', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'board', 'Verpflegung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
<div>
{{ macros.service_field(form, 'rentals', 'Leihmaterial', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}, 'Bitte zuerst den Skipass auswählen') }}
{{ macros.checkbox_field(form, 'rentalInsurance', null, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}, null) }}
</div>
{% if form.bodyDimensions is defined %}
<div class="grid gap-y-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% else %}
<div>{# empty slot to maintain grid layout #}</div>
{% endif %}
<div class="col-span-2">
{# Insurance display for edit mode (read-only) #}
{% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and form.vars.data.participant.insurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{{ form.vars.data.participant.insurance.label }}
{% if form.vars.data.participant.insurance.price and form.vars.data.participant.insurance.price > 0 %}
<span class="text-gray-500">(€{{ form.vars.data.participant.insurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
</div>
</fieldset>
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
{% else %}
{% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{% set applicantInsurance = bookingDto.participants[0].insurance %}
{% if applicantInsurance %}
{{ applicantInsurance.label }}
{% if applicantInsurance.price and applicantInsurance.price > 0 %}
<span class="text-gray-500">(€{{ applicantInsurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
<span class="italic text-gray-500 ml-2"> wie Anmelder</span>
{% else %}
<span class="italic">wie Anmelder</span>
{% endif %}
</div>
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
{# Bulk insurance booking checkbox (applicant only) #}
{% if form.bulkInsuranceBooking is defined %}
{{ form_row(form.bulkInsuranceBooking) }}
{% endif %}
{% if form.insurance is defined %}
{{ form_row(form.insurance, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
},
'label': false
}) }}
{% else %}
<div class="text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% endif %}
{% endif %}
</div>
</div>
{# Transportation Services Section #}
<div class="mt-6 border-t pt-4">
<h3 class="font-semibold text-lg mb-4">Anreise</h3>
<div class="grid grid-cols-2 gap-4">
{% if form.transportationOutbound is defined %}
{{ form_row(form.transportationOutbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.transportationInbound is defined %}
{{ form_row(form.transportationInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.pickup is defined %}
<div class="col-span-2 mt-4">
{{ form_row(form.pickup, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
</div>
{% endif %}
{% if form.parking is defined %}
<div class="mt-4">
{{ form_row(form.parking, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
</div>
{% endif %}
{% if form.licensePlate is defined %}
<div class="mt-4">
{{ form_row(form.licensePlate) }}
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Voucher fields - available for all participants regardless of eligibility #}
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
<div class="mt-6 border-t pt-4">
<h3 class="font-semibold text-lg mb-4">Gutscheine</h3>
<div class="grid grid-cols-2 gap-4">
{% if form.purchaseVoucherCode is defined %}
{{ form_row(form.purchaseVoucherCode) }}
{% endif %}
{% if form.promoVoucherCode is defined %}
{{ form_row(form.promoVoucherCode) }}
{% endif %}
</div>
</div>
{% endif %}
</div> </div>
<div class="flex justify-between mt-8"> {# Address #}
{% if cancelRouteName is defined %} {% if form.address is defined %}
<a href="{{ path(cancelRouteName, cancelRouteParams|default({})) }}" <div class="grid lg:grid-cols-2 lg:gap-x-8">
class="button bg-button bg-button--secondary" {# Address field is in form - render as editable form fields #}
data-action="click->loading#show" {{ form_row(form.address.street) }}
{{ qa_attribute('btn-cancel') }}> {{ form_row(form.address.postCode) }}
Abbrechen {{ form_row(form.address.city) }}
</a> {{ form_row(form.address.country) }}
{% else %} </div>
<a href="{{ path('app_booking_create_step_2') }}" {% elseif is_static_text('address', bookingDto, participantIndex) %}
class="button bg-button bg-button--secondary" {# Address excluded from form but should render as static text #}
data-action="click->loading#show" {# For applicant (index 0), use booking.applicant.address which has full data from BPN #}
{{ qa_attribute('btn-cancel') }}> {# For other participants, use participants[index].address (may only have country in BPN response) #}
Abbrechen {% set addressSource = (0 == participantIndex and bookingDto.booking) ? bookingDto.booking.applicant : bookingDto.participants[participantIndex] %}
</a> <div>
<label class="font-semibold mb-1 block">Adresse</label>
{% if addressSource.address %}
<div class="text-sm text-gray-600">
{% if addressSource.address.street %}{{ addressSource.address.street }}<br>{% endif %}
{% if addressSource.address.postCode or addressSource.address.city %}
{{ addressSource.address.postCode }} {{ addressSource.address.city }}<br>
{% endif %}
{% if addressSource.address.country %}{{ addressSource.address.country|map_country }}{% endif %}
</div>
{% else %}
<div class="text-sm text-gray-600">-</div>
{% endif %}
</div>
{% endif %} {% endif %}
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit') }}>
Speichern
</button>
</div>
{{ form_rest(form) }} {# Room assignment - hidden until date of birth is provided, or shown as static text in edit mode #}
{{ form_end(form) }} {% if form.assignedRoomId is defined or is_static_text('assignedRoomId', bookingDto, participantIndex) %}
</div> {% set assignedRoom = bookingDto.travel.getRoomById(form.vars.data.participant.assignedRoomId) %}
{% set roomLabel = assignedRoom ? assignedRoom.label : 'Kein Zimmer zugewiesen' %}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ macros.field_or_static(form, 'assignedRoomId', 'Zimmer', roomLabel, bookingDto, participantIndex, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{% endif %}
{# Service selection #}
{% if isEligible %}
{{ macros.service_field(form, 'skiPass', 'Skipass', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'courses', 'Kurse', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'additionalServices', 'Zusatzleistungen', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'board', 'Verpflegung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% set htmxAttr = {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
} %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.rentals is defined and not form.rentals.vars.valid }) }}">
Leihmaterial
{% if form.rentals is defined and form.rentals.vars.required %}<span>*</span>{% endif %}
</legend>
{% if form.rentals is defined %}
<table class="w-full table-fixed border-collapse">
{% for child in form.rentals %}
{% set choiceData = form.rentals.vars.choices[loop.index0].data %}
{{ macros.service_choice_row(child, choiceData.price|default(null), child.vars.attr['data-description']|default(null), 'Details', htmxAttr) }}
{% endfor %}
{% if form.rentalInsurance is defined %}
{% set rentalInsuranceService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE'), true, true)|first %}
{{ macros.checkbox_row(
form.rentalInsurance,
constant('App\\BusProNet\\Constants::SERVICE_LABELS')[constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE')],
rentalInsuranceService.price|default(null),
form.rentalInsurance.vars.help|default(null),
'Info',
htmxAttr
) }}
{% endif %}
</table>
{% else %}
<div class="p-2 text-sm text-gray-500">Bitte zuerst den Skipass auswählen</div>
{% endif %}
</fieldset>
{% if form.rentals is defined %}
{{ form_errors(form.rentals) }}
{{ form_help(form.rentals) }}
{% endif %}
</div>
{% if form.bodyDimensions is defined %}
<div class="grid lg:grid-cols-3 lg:gap-x-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% endif %}
{# Insurance display for edit mode (read-only) - only show if insurance is selected #}
{% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') %}
{% if form.vars.data.participant.insurance %}
{% set editInsurance = form.vars.data.participant.insurance %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung
</legend>
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ editInsurance.label }}</div>
{%- if editInsurance.getAllUrlsProductInfo() is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in editInsurance.getAllUrlsProductInfo() -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ editInsurance.getAllUrlsProductInfo()|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if editInsurance.price and editInsurance.price > 0 %}
{{ editInsurance.price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
</fieldset>
</div>
{% endif %}
{# In edit mode without insurance: don't show the section at all #}
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
{% else %}
{% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
{% set applicantInsurance = bookingDto.participants[0].insurance %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic"> wie Anmelder</span>
</legend>
{% if applicantInsurance %}
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ applicantInsurance.label }}</div>
{%- if applicantInsurance.getAllUrlsProductInfo() is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in applicantInsurance.getAllUrlsProductInfo() -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ applicantInsurance.getAllUrlsProductInfo()|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if applicantInsurance.price and applicantInsurance.price > 0 %}
{{ applicantInsurance.price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
{% else %}
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie Anmelder</td>
<td class="border border-primary-bg p-2 align-top w-24"></td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
{% endif %}
</fieldset>
</div>
{% else %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.insurance is defined and not form.insurance.vars.valid }) }}">
Reiseversicherung*
</legend>
{% if form.insurance is defined %}
<table class="w-full table-fixed border-collapse">
{% if form.bulkInsuranceBooking is defined %}
{{ macros.checkbox_row(
form.bulkInsuranceBooking,
form.bulkInsuranceBooking.vars.label,
null,
form.bulkInsuranceBooking.vars.attr['data-description']|default(null),
'Info',
htmxAttr
) }}
{% endif %}
{% for child in form.insurance %}
{% set choiceData = form.insurance.vars.choices[loop.index0].data %}
{{ macros.insurance_choice_row(child, choiceData.price|default(null), choiceData ? choiceData.getAllUrlsProductInfo() : [], htmxAttr) }}
{% endfor %}
</table>
{% else %}
<div class="p-2 text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% if form.insurance is defined %}
{{ form_errors(form.insurance) }}
{{ form_help(form.insurance) }}
{% endif %}
</div>
{% endif %}
{% endif %}
{# Transportation Services Section #}
<h3 class="font-semibold text-lg mb-4">Anreise</h3>
{% if form.transportationOutbound is defined %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': not form.transportationOutbound.vars.valid }) }}">
{{ form.transportationOutbound.vars.label }}
{% if form.transportationOutbound.vars.required %}<span>*</span>{% endif %}
</legend>
<table class="w-full table-fixed border-collapse">
{% for child in form.transportationOutbound %}
{% set choiceData = form.transportationOutbound.vars.choices[loop.index0].data %}
{{ macros.service_choice_row(child, choiceData.price|default(null), child.vars.attr['data-description']|default(null), 'Details', htmxAttr) }}
{% endfor %}
{% if form.parking is defined %}
{% set parkingService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_PARKING'), true)|first %}
{{ macros.checkbox_row(
form.parking,
'Parkplatz',
parkingService.price|default(null),
null,
null,
htmxAttr
) }}
{% endif %}
</table>
</fieldset>
{{ form_errors(form.transportationOutbound) }}
{{ form_help(form.transportationOutbound) }}
</div>
{% endif %}
{% if form.transportationInbound is defined %}
{{ form_row(form.transportationInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.pickup is defined %}
{{ form_row(form.pickup, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.licensePlate is defined %}
{{ form_row(form.licensePlate) }}
{% endif %}
{% endif %}
{# Voucher fields - available for all participants regardless of eligibility #}
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
<h3 class="font-semibold text-lg mb-4">
Gutscheine
</h3>
<div class="grid lg:grid-cols-2 lg:gap-x-4">
{% if form.purchaseVoucherCode is defined %}
{{ form_row(form.purchaseVoucherCode) }}
{% endif %}
{% if form.promoVoucherCode is defined %}
{{ form_row(form.promoVoucherCode) }}
{% endif %}
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}
{# Sidebar summary with conditional OOB swap - only render for HTMX requests #} {# Sidebar summary with conditional OOB swap - only render for HTMX requests #}
{% if htmx_oob_swap|default(false) %} {% if htmx_oob_swap|default(false) %}
{% block booking_summary %} {% block booking_summary %}
<div id="booking-summary" hx-swap-oob="true"> <div id="booking-summary" hx-swap-oob="innerHTML">
{% include 'booking/_summary.html.twig' with { {% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto, 'bookingCreateDto': bookingDto,
'summaryData': summaryData, 'summaryData': summaryData,
+218 -171
View File
@@ -1,193 +1,240 @@
<div class="booking-summary p-8 bg-gray-50 rounded-lg border sticky top-4"> <div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg">
<h3 class="font-bold text-xl mb-6 text-gray-800">Buchungsübersicht</h3> {# Header - always visible #}
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
{# Inquiry booking notification #} <div class="flex items-center justify-between pb-2">
{% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %} <span class="block uppercase font-semibold">Gesamtpreis</span>
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800"> <span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
<span class="font-semibold">Anfragebuchung:</span> Diese Reise ist ausgebucht. Ihre Buchung wird als Anfrage verarbeitet.
</div> </div>
{% endif %} <div class="flex items-center justify-between">
<span class="block uppercase font-semibold">Buchungsübersicht</span>
{# Mutability information (edit mode only) #} <button type="button"
{% if bookingCreateDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and mutableData %} class="lg:hidden button button--small bg-primary-light"
<div class="mb-6 pb-4 border-b border-gray-200"> data-action="toggle#toggle">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Aktualisierung möglich bis</h4> <svg class="w-4 h-4 text-white transition-transform"
<ul class="space-y-1 text-sm text-gray-600"> data-toggle-target="icon"
<li class="flex items-start"> xmlns="http://www.w3.org/2000/svg"
<span class="mr-2">•</span> viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span> </button>
<span class="font-medium">Zusatzleistungen:</span>
{% if mutableData.items['additional_services'].mutable %}
{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Beförderung:</span>
{% if mutableData.items['transportation'].mutable %}
{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Zustiege:</span>
{% if mutableData.items['pickup'].mutable %}
{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
</ul>
</div>
{% endif %}
{# Travel Information #}
<div class="mb-6 pb-4 border-b border-gray-200">
{% if summaryData.cmsData.hotel.images is defined %}
<img src="{{ summaryData.cmsData.hotel.images.resized.l[0].url }}" alt="{{ summaryData.cmsData.hotel.images.resized.s[0].alt }}" class="w-full rounded mb-4">
{% endif %}
<div class="space-y-2 text-sm text-gray-600">
<div><span class="font-medium">Reise:</span> {{ bookingCreateDto.travel.label }}</div>
{% if summaryData.cmsData.region is defined %}
<div><span class="font-medium">Gebiet:</span> {{ summaryData.cmsData.region.name }}</div>
{% endif %}
{% if summaryData.cmsData.country is defined %}
<div><span class="font-medium">Land:</span> {{ summaryData.cmsData.country.name }}</div>
{% endif %}
<div><span class="font-medium">Zeitraum:</span> {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}</div>
<div><span class="font-medium">Unterkunft:</span> {{ bookingCreateDto.travel.hotel.name }}{% if summaryData.cmsData.hotel.address is defined %}, {{ summaryData.cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}</div>
<div><span class="font-medium">Anzahl Teilnehmer:</span> {{ summaryData.participantCount }}</div>
</div> </div>
</div> </div>
{# Rooms Section #} {# Content - toggleable on mobile, always visible on desktop #}
{% if summaryData.pricingData.rooms is not empty %} <div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto px-4 lg:px-8 py-8"
<div class="mb-6 pb-4 border-b border-gray-200"> data-toggle-target="content">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Unterkunft</h4> {# Travel Information #}
<div class="space-y-2"> <div class="pb-4">
{% for roomPricing in summaryData.pricingData.rooms %} <table>
<div class="flex justify-between items-start"> <tr>
<div class="text-sm text-gray-600"> <th class="text-left align-top border-r-2 border-gray-300 pr-2">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span> Reise
{% if summaryData.assignmentCounts[roomPricing.roomId] is defined %} </th>
<span class="block text-xs text-gray-500">{{ summaryData.assignmentCounts[roomPricing.roomId] }} belegt</span> <td class="pl-2">
{% endif %} {{ bookingCreateDto.travel.label }}
</div> </td>
<div class="text-right"> </tr>
<div class="text-gray-900"> <tr>
{{ roomPricing.totalPrice|number_format(2, ',', '.') }} <th class="text-left align-top border-r-2 border-gray-300 pr-2">
</div> Zeitraum
<div class="text-xs text-gray-500"> </th>
{{ roomPricing.unitPrice|number_format(2, ',', '.') }} pro Person <td class="pl-2">
</div> {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}
</div> </td>
</div> </tr>
{% endfor %} <tr>
</div> <th class="text-left align-top border-r-2 border-gray-300 pr-2">
Unterkunft
</th>
<td class="pl-2">
{{ bookingCreateDto.travel.hotel.name }}
</td>
</tr>
<tr>
<th class="text-left align-top border-r-2 border-gray-300 pr-2">
Teilnehmer
</th>
<td class="pl-2">
{{ summaryData.participantCount }}
</td>
</tr>
</table>
</div> </div>
{% endif %}
{# Services Section #} {% include 'booking/_summary_hotel.html.twig' %}
{% if summaryData.pricingData.services is not empty %}
<div class="mb-6"> {# Inquiry booking notification #}
<h4 class="font-semibold text-lg mb-3 text-gray-800">Leistungen</h4> {% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %}
{% for serviceGroup in summaryData.pricingData.services %} <div>
<div class="mb-4 last:mb-0"> Deine Buchung wird als Anfrage verarbeitet.
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5> </div>
<div class="space-y-1 ml-4"> {% endif %}
{% for servicePricing in serviceGroup.services %}
<div class="flex justify-between items-center text-sm"> {# Mutability information (edit mode only) #}
<span class="text-gray-600"> {% if bookingCreateDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and mutableData %}
{{ servicePricing.participantCount }}x {{ servicePricing.label }} <div class="border border-primary-bg mb-4">
</span> <div class="p-2 bg-primary-bg uppercase font-semibold">
<span class="font-medium text-gray-900"> Aktualisierung möglich bis
{{ servicePricing.totalPrice|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
</div> </div>
{% endfor %} <table class="w-full table-fixed">
</div> <tr>
{% endif %} <td class="p-2 align-top">
Zusatzleistungen
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['additional_services'].mutable %}
{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
<tr>
<td class="p-2 align-top">
Beförderung
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['transportation'].mutable %}
{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
<tr>
<td class="p-2 align-top">
Zustiege
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['pickup'].mutable %}
{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
</table>
</div>
{% endif %}
{# Surcharges Section (Edit mode only) #} {# Rooms Section #}
{% if summaryData.pricingData.surcharges is defined and summaryData.pricingData.surcharges is not empty %} {% if summaryData.pricingData.rooms is not empty %}
<div class="mb-6 pb-4 border-b border-gray-200"> <div class="border border-primary-bg mb-4">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Zuschläge</h4> <div class="p-2 bg-primary-bg uppercase font-semibold">
<div class="space-y-1"> Unterkunft
{% for surchargePricing in summaryData.pricingData.surcharges %} </div>
<div class="flex justify-between items-center text-sm"> <table class="w-full table-fixed">
<span class="text-gray-600"> {% for roomPricing in summaryData.pricingData.rooms %}
{{ surchargePricing.participantCount }}x {{ surchargePricing.label }} <tr>
</span> <td class="p-2 align-top">
<span class="font-medium text-gray-900"> <div>{{ roomPricing.quantity }}x {{ roomPricing.label }}</div>
{{ surchargePricing.totalPrice|number_format(2, ',', '.') }} {% if summaryData.assignmentCounts[roomPricing.roomId] is defined %}
</span> <div class="text-sm text-gray-600">{{ summaryData.assignmentCounts[roomPricing.roomId] }}x Erwachsener</div>
{% endif %}
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{{ roomPricing.totalPrice | format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Services Section #}
{% if summaryData.pricingData.services is not empty %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Leistungen
</div>
{% for serviceGroup in summaryData.pricingData.services %}
<div class="p-2 text-primary-dark/70 uppercase font-semibold">
{{ serviceGroup.groupName }}
</div> </div>
<table class="w-full table-fixed">
{% for servicePricing in serviceGroup.services %}
<tr>
<td class="px-2 pb-2 align-top">
{{ servicePricing.participantCount }}x {{ servicePricing.label }}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ servicePricing.totalPrice | format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
{% endfor %} {% endfor %}
</div> </div>
</div> {% endif %}
{% endif %}
{# Total Section #} {# Surcharges Section (Edit mode only) #}
{% if summaryData.pricingData.grandTotal is defined and summaryData.pricingData.grandTotal > 0 %} {% if summaryData.pricingData.surcharges is defined and summaryData.pricingData.surcharges is not empty %}
<div class="pt-4 border-t-2 border-gray-300"> <div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Zu-/Abschläge
</div>
<table class="w-full table-fixed">
{% for surchargePricing in summaryData.pricingData.surcharges %}
<tr>
<td class="px-2 pb-2 align-top">
{{ surchargePricing.participantCount }}x {{ surchargePricing.label }}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ surchargePricing.totalPrice|format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Total Section #}
{% if summaryData.totalPrice > 0 %}
{# Show subtotal and voucher discounts when vouchers are applied #} {# Show subtotal and voucher discounts when vouchers are applied #}
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %} {% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
{% if acceptedVouchers and acceptedVouchers.hasVouchers() %} {% if acceptedVouchers and acceptedVouchers.hasVouchers() %}
{# Subtotal before vouchers #} <div class="border border-primary-bg mb-4">
<div class="flex justify-between items-center mb-2"> <div class="p-2 bg-primary-bg uppercase font-semibold">
<span class="text-gray-600">Gesamtpreis:</span> Kostenübersicht
<span class="text-gray-900"> </div>
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }} <table class="w-full table-fixed">
</span> {# Subtotal before vouchers #}
<tr>
<td class="px-2 pb-2 align-top">
Gesamtpreis
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ summaryData.totalPrice|format_currency('EUR') }}
</td>
</tr>
{# Voucher discounts breakdown #}
{% for voucher in acceptedVouchers.vouchers %}
<tr>
<td class="px-2 pb-2 align-top">
{% if voucher.promotional %}
Aktionsgutschein ({{ voucher.code }})
{% elseif voucher.goodwill %}
Kulanzgutschein ({{ voucher.code }})
{% elseif voucher.purchase %}
Kaufgutschein ({{ voucher.code }})
{% endif %}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
- {{ voucher.amount|format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div> </div>
{# Voucher discounts breakdown #}
<div class="mb-3 space-y-1">
{% for voucher in acceptedVouchers.vouchers %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{% if voucher.promotional %}
Aktionsgutschein ({{ voucher.code }})
{% elseif voucher.goodwill %}
Kulanzgutschein ({{ voucher.code }})
{% elseif voucher.purchase %}
Kaufgutschein ({{ voucher.code }})
{% endif %}
</span>
<span class="font-medium text-green-600">
-€{{ voucher.amount|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
{# Amount to pay after vouchers #} {# Amount to pay after vouchers #}
<div class="flex justify-between items-center pt-2 border-t border-gray-200"> <div class="flex items-center justify-between px-2 pb-2">
<span class="font-bold text-lg text-gray-800">Zu zahlen:</span> <span class="block uppercase font-semibold">Zu zahlen</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}> <span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
</span>
</div> </div>
{% else %} {% else %}
<div class="flex justify-between items-center"> <div class="flex items-center justify-between pb-2">
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span> <span class="block uppercase font-semibold">Gesamtpreis</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}> <span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div> </div>
{% endif %} {% endif %}
</div> {% endif %}
{% endif %} </div>
</div> </div>
@@ -0,0 +1,13 @@
{% if summaryData.cmsData.hotel.images is defined %}
<div class="grid grid-cols-3 gap-x-4 py-4 border-t border-primary-bg">
<img src="{{ summaryData.cmsData.hotel.images.resized.l[0].url }}"
alt="{{ summaryData.cmsData.hotel.images.resized.l[0].alt }}"
class="block w-full h-auto">
<div class="col-span-2">
<span class="block font-semibold uppercase">{{ summaryData.cmsData.hotel.name }}</span>
{% if summaryData.cmsData.hotel.address is defined %}
{{ summaryData.cmsData.hotel.address | nl2br }}
{% endif %}
</div>
</div>
{% endif %}
+80 -82
View File
@@ -1,98 +1,96 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} <div class="px-4 lg:px-8 py-8 lg:py-16">
<h1 class="mb-4"> {% include '_partials/_flashes.html.twig' %}
Neue Buchung
</h1>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6"> <h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
<h2 class="text-blue-900 font-semibold text-lg mb-3"> Neue<br>Buchung
Optional: Anmelden für schnelleres Buchen </h1>
</h2>
<p class="text-blue-800 mb-2">
Melde dich an, um deine persönlichen Daten automatisch in die Buchung zu übernehmen.
Dies ist <strong>vollständig optional</strong> du kannst auch ohne Anmeldung als Gast fortfahren.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div class="text-white uppercase pb-4 ld:pb-8">
{# Login Form #} {{ travel_title }}
<div> <br>
<h3 class="mb-4 font-semibold text-lg"> {{ travel_date_from | date('d.m.Y') }} - {{ travel_date_to | date('d.m.Y') }}
Mit bestehendem Account anmelden
</h3>
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
<div class="mb-4">
<label for="username" class="mb-1 font-semibold">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4">
<label for="password" class="mb-1 font-semibold">
Passwort:
</label>
<input type="password"
id="password"
name="_password"
class="form-field"
required
autocomplete="current-password">
</div>
<div class="flex flex-col space-y-2">
<button type="submit" class="button bg-button">
Anmelden und fortfahren
</button>
<a href="{{ path('app_reset_password') }}"
class="text-sm text-center text-blue-600 hover:underline">
Passwort vergessen?
</a>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div> </div>
{# Continue as Guest #} <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div> <div>
<h3 class="mb-4 font-semibold text-lg"> <a href="{{ path('app_booking_create_step_1') }}" class="button button--secondary mb-4">
Als Gast fortfahren Als Gast fortfahren
</h3> </a>
<p class="mb-4 text-gray-700"> <h2 class="text-base text-white">
Du kannst auch ohne Anmeldung buchen. Deine persönlichen Daten gibst du dann im nächsten Schritt ein.
</p>
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary w-full block text-center">
Als Gast fortfahren
</a>
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded">
<h4 class="font-semibold mb-2 text-sm">
Noch kein Account? Noch kein Account?
</h4> </h2>
<p class="text-sm text-gray-700"> <p class="text-white">
Nach Abschluss deiner Buchung wird automatisch ein Account für dich erstellt. Nach Abschluss deiner Buchung wird automatisch ein Account für dich erstellt.
Du erhältst dann Zugangsdaten per E-Mail und kannst deine Buchungen jederzeit verwalten. Du erhältst dann Zugangsdaten per E-Mail und kannst deine Buchungen jederzeit verwalten.
</p> </p>
</div> </div>
</div> <div>
</div> <h2 class="text-base text-white">
Mit bestehendem Account anmelden
</h2>
{% include 'booking/_cancel_link.html.twig' %} {% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4">
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<input type="password"
id="password"
name="_password"
class="form-field"
required
autocomplete="current-password">
</div>
<button type="submit" class="button button--primary">
Anmelden und fortfahren
</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
<a href="{{ path('app_reset_password') }}"
class="text-sm underline text-white">
Passwort vergessen?
</a>
</div>
</div>
</div>
{% endblock %}
{% block footer %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4">
<button type="button"
hx-get="{{ path('app_booking_cancel') }}"
hx-target="body"
hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
</div>
{% endblock %} {% endblock %}
+10 -28
View File
@@ -5,34 +5,16 @@
{% block body %} {% block body %}
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<div class="max-w-lg mx-auto"> <div class="max-w-lg mx-auto">
<div class="bg-red-50 border border-red-200 rounded-lg p-6"> {% set errorMessages = app.flashes('error')|default(['Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.']) %}
<div class="flex"> {% include '_partials/_alert.html.twig' with {
<div class="flex-shrink-0"> level: 'error',
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> title: 'Booking Error',
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /> messages: errorMessages
</svg> } %}
</div>
<div class="ml-3"> <a href="https://www.ep-reisen.de" class="button button--secondary">
<h3 class="text-sm font-medium text-red-800"> Zur Startseite
Booking Error </a>
</h3>
<div class="mt-2 text-sm text-red-700">
{% for flash_message in app.flashes('error') %}
<p>{{ flash_message }}</p>
{% else %}
<p>Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.</p>
{% endfor %}
</div>
<div class="mt-4">
<div class="flex space-x-2">
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--secondary">
Zur Startseite
</a>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+98 -57
View File
@@ -1,64 +1,105 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% macro stepFormField(form) %}
<div class="grid grid-cols-3 pb-2">
<div class="col-span-2 pr-2">
<div class="font-semibold">
{{ form.vars.label_room }}
</div>
<div class="text-gray-500">
{{ form.vars.label_price }}
</div>
</div>
<div>
{{ form_row(form, {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change delay:300ms',
}
}) }}
</div>
</div>
{% endmacro %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {{ form_start(form, {
<h1>Neue Buchung</h1> 'attr': {
<div class="grid grid-cols-3 gap-8"> 'class': 'flex-1 flex flex-col min-h-0',
<div id="form-wrapper" class="col-span-2"> 'hx-post': path('app_booking_create_step_1'),
<h2 class="pb-4"> 'hx-target': '#form-wrapper',
Unterkunft 'hx-swap': 'innerHTML'
</h2> }
{{ form_start(form) }} }) }}
{% do form.roomSelections.setRendered %} {# Pagination - mobile only (above summary) #}
{# This block contains the room selection form fields #} <div class="lg:hidden">
{% block room_selection_form %} {% include 'booking/_pagination.html.twig' with { 'current_step': 1 } %}
<div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}> </div>
{{ form_errors(form) }} <div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{% if groupedRooms.by_room is not empty %} {% block booking_summary %}
<h3> <div id="booking-summary"
Zimmer class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
</h3> {{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% for roomId, room in groupedRooms.by_room %} {% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
{{ form_row(form.roomSelections[roomId], { {% include 'booking/_summary.html.twig' with {
'attr': { 'bookingCreateDto': bookingCreateDto,
'hx-post': path('app_booking_create_step_1_refresh'), 'summaryData': summaryData
'hx-swap': 'none', } %}
'hx-trigger': 'change delay:300ms', </div>
} {% endblock %}
}) }} {% block form %}
{% endfor %} <div id="form-wrapper"
{% endif %} class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{% if groupedRooms.by_pax is not empty %} {# Flash messages - must be inside form-wrapper for HTMX swap to display them #}
<h3> {% include '_partials/_flashes.html.twig' %}
Betten {# Pagination - desktop only (fixed above scrollable content) #}
</h3> <div class="hidden lg:block shrink-0">
{% for roomId, room in groupedRooms.by_pax %} {% include 'booking/_pagination.html.twig' with { 'current_step': 1 } %}
{{ form_row(form.roomSelections[roomId], { </div>
'attr': { <div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
'hx-post': path('app_booking_create_step_1_refresh'), <h2 class="pb-4">
'hx-swap': 'none', Unterkunft
'hx-trigger': 'change delay:300ms' </h2>
} {% do form.roomSelections.setRendered %}
}) }} {% block room_selection_form %}
{% endfor %} <div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% endif %} {{ form_errors(form) }}
{% if groupedRooms.by_room is not empty %}
<h3>
Zimmer
</h3>
{% for roomId, room in groupedRooms.by_room %}
{{ _self.stepFormField(form.roomSelections[roomId]) }}
{% endfor %}
{% endif %}
{% if groupedRooms.by_pax is not empty %}
<h3>
Betten
</h3>
{% for roomId, room in groupedRooms.by_pax %}
{{ _self.stepFormField(form.roomSelections[roomId]) }}
{% endfor %}
{% endif %}
</div>
{% endblock %}
</div>
</div> </div>
{% endblock %} {% endblock %}
<div class="flex justify-end pt-4">
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit')}} {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div> </div>
{% block booking_summary %} <div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}> <div class="flex justify-between" hx-disinherit="*">
{% include 'booking/_summary.html.twig' with { <button type="button"
'bookingCreateDto': bookingCreateDto, hx-get="{{ path('app_booking_cancel') }}"
'summaryData': summaryData hx-target="body"
} %} hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit')}} {{ stimulus_action('loading', 'toggle') }}>
Weiter zu Schritt 2
</button>
</div> </div>
{% endblock %} </div>
</div> {{ form_rest(form) }}
{{ form_end(form) }}
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
+68 -62
View File
@@ -1,74 +1,80 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1> {{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'hx-post': path('app_booking_create_step_2'),
'hx-target': '#main-content',
'hx-swap': 'innerHTML scroll:top'
}
}) }}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap|default(false) %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
{% block participant_cards %}
<div id="main-content"
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Teilnehmer
</h2>
<div class="grid grid-cols-3 gap-8"> {# Display form-level validation errors #}
{# Main content area - cards grid #} {% if form.vars.submitted and not form.vars.valid %}
{% block participant_cards %} {% include '_partials/_alert.html.twig' with {
<div id="main-content" class="col-span-2"> level: 'error',
{{ form_start(form, { title: 'Bitte überprüfe die Teilnehmerdaten',
'method': 'POST', messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
'action': path('app_booking_create_step_2'), } %}
'attr': { {% endif %}
'hx-post': path('app_booking_create_step_2'),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
<h2>Teilnehmer</h2> <div class="divide-y divide-gray-200">
{% for cardData in cardsData %}
{# Display form-level validation errors #} {% include 'booking/_participant_card.html.twig' with {
{% if form.vars.submitted and not form.vars.valid %} 'cardData': cardData,
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg" {{ qa_attribute('alert-form-errors') }}> 'index': loop.index0,
<div class="flex items-start"> 'participantNumber': loop.index,
<svg class="w-5 h-5 text-red-600 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> 'mode': 'create'
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path> } %}
</svg> {% endfor %}
<div>
<p class="font-semibold text-red-800 mb-1">Bitte überprüfe die Teilnehmerdaten</p>
<p class="text-sm text-red-700">Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.</p>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endblock %}
</div>
<div id="participant-cards-grid" class="space-y-4"> <div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
{% for cardData in cardsData %} <div class="flex justify-between" hx-disinherit="*">
{% include 'booking/_participant_card.html.twig' with { <button type="button"
'cardData': cardData, hx-get="{{ path('app_booking_cancel') }}"
'index': loop.index0, hx-target="body"
'mode': 'create' hx-swap="beforeend"
} %} class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
{% endfor %} <svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</div> </button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
<div class="flex justify-between mt-8"> Weiter zu Schritt 3
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-back') }} {{ stimulus_action('loading', 'toggle') }}>
Zurück
</a>
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Weiter
</button> </button>
</div> </div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div> </div>
{% endblock %} {{ form_rest(form) }}
{{ form_end(form) }}
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap|default(false) %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
</div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
@@ -1,23 +1,54 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {{ form_start(form, {
<h1>Neue Buchung</h1> 'attr': {
'class': 'flex-1 flex flex-col min-h-0',
<div class="grid grid-cols-3 gap-8"> 'novalidate': 'novalidate',
{# Main content area - participant form #} 'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
<div id="main-content" class="col-span-2"> 'hx-target': '#main-content',
{% include 'booking/_participant_form.html.twig' %} 'hx-swap': 'innerHTML scroll:top'
}
}) }}
{# Pagination - mobile only (above form) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div> </div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{# Sidebar summary #} {# Form area - full width mobile, 3 cols desktop #}
<div id="booking-summary"> <div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include 'booking/_summary.html.twig' with { {# Pagination - desktop only (fixed above scrollable content) #}
'bookingCreateDto': bookingDto, <div class="hidden lg:block shrink-0">
'summaryData': summaryData {% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
} %} </div>
<div id="main-content" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{% include 'booking/_participant_form.html.twig' %}
</div>
</div>
</div> </div>
</div> <div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
{% include 'booking/_cancel_link.html.twig' %} <a href="{{ path('app_booking_create_step_2') }}"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
{{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %} {% endblock %}
+81 -57
View File
@@ -1,68 +1,92 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% block content %} {% block content %}
<h1>Neue Buchung</h1> {{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_3'),
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 3 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-3 min-h-0 relative">
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
{% block form %}
<div id="form-wrapper"
class="flex-1 order-2 lg:order-1 lg:col-span-2 bg-white flex flex-col min-h-0">
{# Flash messages - must be inside form-wrapper for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 3 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Zahlungsart
</h2>
<div class="grid grid-cols-3 gap-8"> {# Payment method selection with HTMX #}
<div id="form-wrapper" class="col-span-2"> <div id="form-payment"
{% include '_partials/_flashes.html.twig' %} hx-post="{{ path('app_booking_create_step_3_refresh') }}"
hx-trigger="change"
hx-target="#form-payment"
hx-select="#form-payment"
hx-swap="outerHTML">
{{ form_row(form.paymentMethod) }}
<h2 class="mb-6">Zahlungsart</h2> {# Bank account section - conditionally rendered #}
{% if form.bankAccount is defined %}
<h3 class="font-semibold text-lg mb-4">
Bankverbindung
</h3>
{{ form_start(form, { <div class="space-y-4">
'attr': { {{ form_row(form.bankAccount.iban) }}
'novalidate': 'novalidate', {{ form_row(form.bankAccount.accountHolder) }}
'hx-post': path('app_booking_create_step_3'), {{ form_row(form.bankAccount.bankName) }}
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
{# Payment method selection with HTMX #} <div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
<div id="form-payment" {{ form_row(form.bankAccount.sepaMandateAccepted, {
hx-post="{{ path('app_booking_create_step_3_refresh') }}" 'label_attr': {'class': 'text-sm'}
hx-trigger="change" }) }}
hx-target="#form-payment" </div>
hx-select="#form-payment" </div>
hx-swap="outerHTML"> {% endif %}
{{ form_row(form.paymentMethod) }}
{# Bank account section - conditionally rendered #}
{% if form.bankAccount is defined %}
<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 mt-4">
<h3 class="font-semibold text-lg mb-4">Bankverbindung</h3>
<div class="space-y-4">
{{ form_row(form.bankAccount.iban) }}
{{ form_row(form.bankAccount.accountHolder) }}
{{ form_row(form.bankAccount.bankName) }}
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
{{ form_row(form.bankAccount.sepaMandateAccepted, {
'label_attr': {'class': 'text-sm'}
}) }}
</div>
</div> </div>
</div> </div>
{% endif %} </div>
</div> {% endblock %}
<div class="flex justify-between pt-6">
<a href="{{ path('app_booking_create_step_2') }}" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_end(form) }}
</div> </div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div id="booking-summary"> <div class="flex justify-between" hx-disinherit="*">
{% include 'booking/_summary.html.twig' with { <button type="button"
'bookingCreateDto': bookingCreateDto, hx-get="{{ path('app_booking_cancel') }}"
'summaryData': summaryData hx-target="body"
} %} hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Weiter zu Schritt 4
</button>
</div>
</div> </div>
</div> {{ form_rest(form) }}
{{ form_end(form) }}
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
+49 -30
View File
@@ -3,37 +3,56 @@
{% block title %}Buchung erfolgreich{% endblock %} {% block title %}Buchung erfolgreich{% endblock %}
{% block content %} {% block content %}
<div class="max-w-2xl mx-auto text-center py-12"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<h1 class="text-3xl font-bold mb-4">Buchung erfolgreich abgeschlossen</h1> <div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Vielen Dank
<br>
für deine Buchung
</h1>
<p class="text-xl mb-8"> <div class="text-xl text-white mb-8">
Deine Buchungsnummer lautet <strong class="font-mono">{{ bookingNumber }}</strong> Deine Buchungsnummer lautet
</p> <br>
<strong class="font-mono">{{ bookingNumber }}</strong>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<p class="text-gray-700">
Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details zu Deiner Buchung.
</p>
</div>
{% if app.user %}
{# Authenticated user - show account-related actions #}
<div class="space-y-4">
<a href="{{ path('app_personal_data') }}" class="button bg-button bg-button--primary inline-block">
Zu meinen persönlichen Daten
</a>
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary inline-block ml-2">
Meine Buchungen anzeigen
</a>
<a href="{{ path('app_logout') }}" class="button bg-button bg-button--outline inline-block ml-2">
Abmelden
</a>
</div> </div>
{% else %}
{# Guest user - show simple homepage link #} <div class="pb-8">
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--primary"> {% include '_partials/_alert.html.twig' with {
Zurück zur Startseite level: 'info',
</a> messages: ['Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details.']
{% endif %} } %}
</div>
{% if app.user %}
{# Authenticated user - show account-related actions #}
<ul class="divide-y divide-primary-bg/40">
<li class="py-4">
<a href="{{ path('app_bookings') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="128" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="64" x2="216" y2="64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="192" x2="216" y2="192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 64 56 80 88 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 128 56 144 88 112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 192 56 208 88 176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Buchungen</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_personal_data') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M160,224c3.67-13.8,16.6-24,32-24s28.33,10.2,32,24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M120,208H40a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6l27.74,20.8a8,8,0,0,0,4.8,1.6H216a8,8,0,0,1,8,8v32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Daten</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_logout') }}" class="flex items-center space-x-2 group">
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">abmelden</span>
</a>
</li>
</ul>
{% else %}
{# Guest user - show simple homepage link #}
<a href="https://www.ep-reisen.de" class="button button--primary">
Zurück zur Startseite
</a>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}
+93 -94
View File
@@ -1,109 +1,108 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
{{ form_start(form, {
{# Header with reload button #} 'attr': {
<div class="flex justify-between items-center pb-8"> 'class': 'flex-1 flex flex-col min-h-0'
<h1 class="text-3xl font-semibold">Buchung bearbeiten</h1> }
}) }}
<button type="button" <div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}" {# Sidebar summary #}
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?" {% block booking_summary %}
{{ stimulus_action('loading', 'toggle') }} <div id="booking-summary"
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded transition-colors"> class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
Änderungen verwerfen {{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
</button> {% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
</div> {% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
{# Grid layout with 2/3 cards + 1/3 summary #} 'summaryData': summaryData,
<div class="grid grid-cols-3 gap-8"> 'mutableData': mutableData|default(null)
{# Main content area - cards grid #} } %}
<div id="main-content" class="col-span-2">
{% block participant_cards %}
{{ form_start(form) }}
<div>
<h2>Teilnehmer</h2>
{% if isDirty %}
<div class="mb-6 p-4 bg-yellow-50 border border-yellow-400 rounded-lg">
<div class="flex items-start">
<svg class="w-6 h-6 text-yellow-600 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<h3 class="font-semibold text-yellow-800">Ungespeicherte Änderungen</h3>
<p class="text-yellow-700 text-sm mt-1">
Du hast Änderungen an der Buchung vorgenommen.
Bitte denke daran, abschließend den 'Buchung aktualisieren' Button zu klicken.
</p>
</div>
</div>
</div>
{% endif %}
{# Display validation errors #}
{% if hasValidationErrors|default(false) %}
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-600 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="font-semibold text-red-800 mb-1">Bitte überprüfe die Teilnehmerdaten</p>
<p class="text-sm text-red-700">Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.</p>
</div>
</div>
</div>
{% endif %}
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'mode': 'edit',
'isCanceled': isCanceled,
'bookingId': bookingData.id
} %}
{% endfor %}
</div> </div>
{% endblock %}
<div class="flex justify-between mt-8"> {# Main content area #}
<button type="button" <div id="main-content"
hx-post="{{ path('app_booking_edit_cancel', {id: bookingData.id}) }}" class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{{ stimulus_action('loading', 'toggle') }} {# Header - desktop only #}
class="button bg-button bg-button--secondary"> <div class="hidden lg:flex shrink-0 px-4 lg:px-8 h-16 items-center justify-between shadow-md relative z-10 bg-white">
Zurück <h1 class="text-xl font-semibold uppercase">
</button> Buchung bearbeiten
</h1>
{% if isDirty %} {% if isDirty %}
<button type="submit" <button type="button"
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}"
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?"
{{ stimulus_action('loading', 'toggle') }} {{ stimulus_action('loading', 'toggle') }}
{% if hasValidationErrors|default(false) %} class="button button--small button--secondary">
disabled Änderungen verwerfen
title="Bitte behebe zuerst alle Validierungsfehler"
{% endif %}
class="button bg-button bg-button--secondary {{ hasValidationErrors|default(false) ? 'opacity-50 cursor-not-allowed' : '' }}">
Buchung aktualisieren
</button> </button>
{% endif %} {% endif %}
</div> </div>
{# Scrollable content #}
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{% block participant_cards %}
<h2 class="pb-4">Teilnehmer</h2>
{% if isDirty %}
{% include '_partials/_alert.html.twig' with {
level: 'warning',
title: 'Ungespeicherte Änderungen',
messages: ['Du hast Änderungen an der Buchung vorgenommen. Bitte denke daran, abschließend den \'Buchung aktualisieren\' Button zu klicken.']
} %}
{% endif %}
{# Display validation errors #}
{% if hasValidationErrors|default(false) %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %}
{% endif %}
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'participantNumber': loop.index,
'mode': 'edit',
'isCanceled': isCanceled,
'bookingId': bookingData.id
} %}
{% endfor %}
</div>
{% endblock %}
</div>
</div> </div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
</div> </div>
{# Sidebar summary #} {# Fixed footer #}
{% block booking_summary %} <div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}> <div class="flex justify-between">
{% include 'booking/_summary.html.twig' with { <button type="button"
'bookingCreateDto': bookingDto, hx-post="{{ path('app_booking_edit_cancel', {id: bookingData.id}) }}"
'summaryData': summaryData, {{ stimulus_action('loading', 'toggle') }}
'mutableData': mutableData|default(null) class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
} %} <svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
{% if isDirty %}
<button type="submit"
{{ stimulus_action('loading', 'toggle') }}
{% if hasValidationErrors|default(false) %}
disabled
title="Bitte behebe zuerst alle Validierungsfehler"
{% endif %}
class="button button--primary {{ hasValidationErrors|default(false) ? 'opacity-50 cursor-not-allowed' : '' }}">
Buchung aktualisieren
</button>
{% endif %}
</div> </div>
{% endblock %} </div>
</div> {{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %} {% endblock %}
+46 -18
View File
@@ -1,24 +1,52 @@
{% extends 'layout.html.twig' %} {% extends 'layout_booking.html.twig' %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {{ form_start(form, {
<h1>Buchung bearbeiten</h1> 'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML scroll:top'
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
</div>
<div class="grid grid-cols-3 gap-8"> {# Form area - full width mobile, 3 cols desktop #}
{# Main content area - participant form #} <div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
<div id="main-content" class="col-span-2"> {# Header - desktop only #}
{% include 'booking/_participant_form.html.twig' %} <div class="hidden lg:flex shrink-0 px-4 lg:px-8 h-16 items-center shadow-md relative z-10 bg-white">
<h1 class="text-xl font-semibold uppercase">Buchung bearbeiten</h1>
</div>
<div id="main-content" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{% include 'booking/_participant_form.html.twig' %}
</div>
</div>
</div> </div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
{# Sidebar summary #} <div class="flex justify-between">
<div id="booking-summary"> <a href="{{ path('app_booking_edit', {id: bookingDto.booking.id}) }}"
{% include 'booking/_summary.html.twig' with { class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
'bookingCreateDto': bookingDto, {{ stimulus_action('loading', 'toggle') }}
'summaryData': summaryData, {{ qa_attribute('btn-cancel') }}>
'mutableData': mutableData|default(null) <svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
} %} </a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Speichern
</button>
</div>
</div> </div>
</div> {{ form_rest(form) }}
{{ form_end(form) }}
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
+100 -87
View File
@@ -1,92 +1,105 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block content %} {% block content %}
<h1 class="text-2xl font-semibold pb-4"> <div class="flex-1 flex flex-col min-h-0">
Meine Buchungen {# Header #}
</h1> <div class="shrink-0 px-4 lg:px-8 py-8 lg:py-16">
{% include '_partials/_flashes.html.twig' %} <h1 class="text-white uppercase pb-4">
<table class="w-full mb-4 responsive"> Meine<br>Buchungen
<thead> </h1>
<tr class="bg-zinc-200"> </div>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
Reise {# Scrollable content area #}
</th> <div class="flex-1 overflow-y-auto p-4 lg:p-8 bg-primary-bg">
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col"> {% include '_partials/_flashes.html.twig' %}
Reisedatum
</th> <div class="space-y-8">
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col"> {% for booking in bookings %}
Buchungsnummer <div class="grid md:grid-cols-2 gap-4 p-2 bg-white rounded-md">
</th> <div class="flex items-start space-x-4">
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col"> <div class="w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
Reisepreis {{ loop.index }}
</th> </div>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col"> <div class="text-xl font-semibold">
offen {{ booking.travelName }}
</th> </div>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col"> </div>
Status <div>
</th> <table class="mb-4">
<th scope="col"> <tr>
<span class="sr-only">Aktionen</span> <th class="text-left border-r-2 border-gray-300 pr-2">
</th> Reisedatum
</tr> </th>
</thead> <td class="pl-2">
<tbody> {% if booking.travelData %}
{% for booking in bookings %} {{ booking.travelData.dateFrom|date('d.m.Y') }} - {{ booking.travelData.dateTo|date('d.m.Y') }}
<tr class="odd:bg-zinc-50 hover:bg-zinc-100 odd:hover:bg-zinc-100"> {% else %}
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" {{ booking.travelDate|date('d.m.Y') }}
data-label="Reise"> {% endif %}
{{ booking.travelName }} </td>
</td> </tr>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" <tr>
data-label="Buchungsdatum"> <th class="text-left border-r-2 border-gray-300 pr-2">
{% if booking.travelData %} Buchungsnr.
{{ booking.travelData.dateFrom|date('d.m.Y') }} - {{ booking.travelData.dateTo|date('d.m.Y') }} </th>
{% else %} <td class="pl-2">
{{ booking.travelDate|date('d.m.Y') }} {{ booking.bookingNumber }}
{% endif %} </td>
</td> </tr>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" <tr>
data-label="Buchungsnummer"> <th class="text-left border-r-2 border-gray-300">
{{ booking.bookingNumber }} Reisepreis
</td> </th>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" <td class="pl-2">
data-label="Reisepreis"> {{ booking.price|format_currency('EUR') }}
{{ booking.price|format_currency('EUR') }} </td>
</td> </tr>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" <tr>
data-label="offen"> <th class="text-left border-r-2 border-gray-300">
{{ booking.balance ? booking.balance|format_currency('EUR') : '-' }} offen
</td> </th>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2" <td class="pl-2">
data-label="Status"> {{ booking.balance ? booking.balance|format_currency('EUR') : '-' }}
{{ booking.status|map_status }} </td>
</td> </tr>
<td class="px-2 py-1 md:p-2"> <tr>
<div class="flex md:flex-col space-x-2 md:space-x-0 md:space-y-1"> <th class="text-left border-r-2 border-gray-300">
{% if booking.editable %} Status
<a href="{{ path('app_booking_edit', { 'id': booking.id }) }}" </th>
{{ stimulus_action('loading', 'toggle') }} <td class="pl-2">
class="button bg-button bg-button--secondary button--small"> {{ booking.status|map_status }}
bearbeiten </td>
</a> </tr>
{% endif %} </table>
<a href="{{ path('app_booking_invoice', { 'id': booking.id }) }}" <div class="flex space-x-2">
class="button bg-button button--small" {% if booking.editable %}
target="_blank"> <a href="{{ path('app_booking_edit', { 'id': booking.id }) }}"
Rechnung {{ stimulus_action('loading', 'toggle') }}
</a> class="button button--primary button--small"
{% if booking.travelInfoUrl is not null %} title="Buchung bearbeiten">
<a href="{{ booking.travelInfoUrl }}" <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
class="button bg-button bg-pink button--small" </a>
target="_blank"> {% endif %}
Reiseinfos <a href="{{ path('app_booking_invoice', { 'id': booking.id }) }}"
</a> class="button button--secondary button--small"
{% endif %} title="Rechnung herunterladen"
target="_blank">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M64,56H40A16,16,0,0,0,24,72h0A16,16,0,0,0,40,88H56a16,16,0,0,1,16,16h0a16,16,0,0,1-16,16H28" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48" y1="48" x2="48" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48" y1="120" x2="48" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,56H224V192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="104" y1="104" x2="224" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="152" x2="224" y2="152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="168" y1="104" x2="168" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% if booking.travelInfoUrl is not null %}
<a href="{{ booking.travelInfoUrl }}"
class="button button--secondary button--small"
title="zu den Reiseinfos"
target="_blank">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M120,120a8,8,0,0,1,8,8v40a8,8,0,0,0,8,8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="124" cy="84" r="12"/></svg>
</a>
{% endif %}
</div>
</div>
</div> </div>
</td> {% endfor %}
</tr> </div>
{% endfor %} </div>
</tbody> </div>
</table>
{% endblock %} {% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Sicher?{% endblock %}
{% block content %}
<div class="pb-8">
Willst du den Buchungsvorgang wirklich abbrechen?
</div>
<div class="flex justify-between">
<button type="button"
hx-post="{{ path('app_booking_cancel') }}"
hx-target="body"
class="button button--small button--primary">
Ja
</button>
<button type="button"
{{ stimulus_action('modal', 'close') }}
class="button button--small button--secondary">
Nein
</button>
</div>
{% endblock %}
+13 -13
View File
@@ -25,7 +25,7 @@
{% endmacro %} {% endmacro %}
{%- block form_row -%} {%- block form_row -%}
{%- set row_attr = row_attr|merge({ 'class': (row_attr.class|default('') ~ ' mb-1')|trim }) -%} {%- set row_attr = row_attr|merge({ 'class': (row_attr.class|default('') ~ ' mb-4')|trim }) -%}
{%- set widget_attr = {} -%} {%- set widget_attr = {} -%}
{%- if help is not empty -%} {%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
@@ -52,9 +52,9 @@
{%- block form_label -%} {%- block form_label -%}
{% set class = 'font-semibold' %} {% set class = 'font-semibold' %}
{% if errors|length %} {% if errors|length %}
{% set class = class ~ ' text-red-700' %} {% set class = class ~ ' text-red-500' %}
{% endif %} {% endif %}
{% set label_attr = label_attr|merge({'class': (label_attr.class|default('') ~ class)|trim}) %} {% set label_attr = label_attr|merge({'class': (label_attr.class|default('') ~ ' ' ~ class)|trim}) %}
{{ parent() }} {{ parent() }}
{%- endblock form_label -%} {%- endblock form_label -%}
@@ -62,7 +62,7 @@
{%- if errors|length > 0 -%} {%- if errors|length > 0 -%}
<ul class="pb-2"> <ul class="pb-2">
{%- for error in errors -%} {%- for error in errors -%}
<li class="text-red-700">{{ error.message }}</li> <li class="text-red-500">{{ error.message }}</li>
{%- endfor -%} {%- endfor -%}
</ul> </ul>
{%- endif -%} {%- endif -%}
@@ -155,7 +155,7 @@
<div class="flex h-6 items-center"> <div class="flex h-6 items-center">
{{ form_widget(form) }} {{ form_widget(form) }}
</div> </div>
<div class="{{ html_classes('ml-2 leading-6', { 'text-red-700': errors|length }) }}"> <div class="{{ html_classes('ml-2 leading-6', { 'text-red-500': errors|length }) }}">
{{ form.vars.label | raw }} {{ form.vars.label | raw }}
{{- form_errors(form) -}} {{- form_errors(form) -}}
</div> </div>
@@ -178,7 +178,7 @@
<div class="flex h-6 items-center"> <div class="flex h-6 items-center">
{{ form_widget(form) }} {{ form_widget(form) }}
</div> </div>
<div class="{{ html_classes('ml-2 leading-6', { 'text-red-700': errors|length }) }}"> <div class="{{ html_classes('ml-2 leading-6', { 'text-red-500': errors|length }) }}">
{{ form.vars.label | raw }} {{ form.vars.label | raw }}
{{- form_errors(form) -}} {{- form_errors(form) -}}
</div> </div>
@@ -236,7 +236,7 @@
role="button" role="button"
class="relative rounded-md border border-gray-300 flex items-start space-x-2 px-3"> class="relative rounded-md border border-gray-300 flex items-start space-x-2 px-3">
<div {{ stimulus_target('multiselect', 'label') }} class="flex-1 min-h-8 py-1.5 text-gray-900 text-sm"></div> <div {{ stimulus_target('multiselect', 'label') }} class="flex-1 min-h-8 py-1.5 text-gray-900 text-sm"></div>
{{ icon('dots', 'w-4 h-4 mt-2') }} <svg class="w-4 h-4 mt-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="12"/><circle cx="128" cy="60" r="12"/><circle cx="128" cy="196" r="12"/></svg>
</div> </div>
<div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-scroll px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden"> <div {{ stimulus_target('multiselect', 'dropdown')}} class="absolute z-10 top-full -mt-1.5 left-0 inset-x-0 max-h-48 overflow-y-scroll px-3 py-2 bg-white shadow-lg rounded-md rounded-t-none border border-gray-300 border-t-0 hidden">
{% for item in form.children %} {% for item in form.children %}
@@ -325,13 +325,13 @@
'data-step-input-target': 'field', 'data-step-input-target': 'field',
'class': 'hidden', 'class': 'hidden',
} }) }} } }) }}
<div class="flex space-x-2"> <div class="flex items-center justify-between">
<button type="button" class="button button--small bg-button" {{ stimulus_target('step-input', 'buttonDec') }} {{ stimulus_action('step-input', 'decrease') }}> <button type="button" class="button button--small bg-white" {{ stimulus_target('step-input', 'buttonDec') }} {{ stimulus_action('step-input', 'decrease') }}>
- <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button> </button>
<div {{ stimulus_target('step-input', 'display') }}></div> <div class="px-2" {{ stimulus_target('step-input', 'display') }}></div>
<button type="button" class="button button--small bg-button--secondary" {{ stimulus_target('step-input', 'buttonInc') }} {{ stimulus_action('step-input', 'increase') }}> <button type="button" class="button button--small button--primary" {{ stimulus_target('step-input', 'buttonInc') }} {{ stimulus_action('step-input', 'increase') }}>
+ <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="40" x2="128" y2="216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button> </button>
</div> </div>
</div> </div>
+5 -8
View File
@@ -1,5 +1,6 @@
<div id="htmx-modal" class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}> <div id="htmx-modal"
<div class="absolute inset-0 w-full h-full bg-black/80" {{ stimulus_action('modal', 'close', 'click') }}></div> class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
<div class="absolute inset-0 w-full h-full backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-2xl"> <div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-2xl">
<div class="relative bg-white py-8 rounded-md"> <div class="relative bg-white py-8 rounded-md">
<div class="flex justify-between pb-4 px-8"> <div class="flex justify-between pb-4 px-8">
@@ -7,9 +8,9 @@
{% block title %}{% endblock %} {% block title %}{% endblock %}
</div> </div>
<button type="button" <button type="button"
class="inline-block" class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
{{ stimulus_action('modal', 'close') }}> {{ stimulus_action('modal', 'close') }}>
{{ icon('close', 'w-6 h-6')}} <svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button> </button>
</div> </div>
<div class="max-h-96 overflow-y-scroll"> <div class="max-h-96 overflow-y-scroll">
@@ -17,10 +18,6 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
<div class="htmx-indicator absolute top-0 left-0 inset-0 bg-white/90 flex items-center justify-center"
id="htmx-modal-indicator">
{% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8'} %}
</div>
</div> </div>
</div> </div>
</div> </div>
+14 -19
View File
@@ -1,26 +1,21 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
{# Global toast controller - persists across all HTMX swaps and page navigations #} <div class="w-screen h-screen {% block background %}bg-outer bg-outer--2{% endblock %}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}>
<div {{ stimulus_controller('toast') }}></div> <div class="max-w-screen-xl h-screen ml-auto flex flex-col overflow-hidden bg-inner">
<div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md">
<div class="{{ html_classes('pb-8', { 'pt-24': is_granted('ROLE_USER') }) }}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}> {% block header %}
<div class="fixed inset-x-0 top-0 z-20"> {% include '_partials/_header.html.twig' %}
<div class="max-w-screen-xl mx-auto flex items-center justify-between bg-primary-dark text-white"> {% endblock %}
{% if is_granted('ROLE_USER') %}
<nav class="w-full flex items-center relative">
<div class="flex-1 block text-xl px-4">My E&amp;P</div>
<div>
{{ knp_menu_render(knp_menu_get('main')) }}
</div>
</nav>
{% endif %}
</div> </div>
<div class="flex-1 flex flex-col min-h-0 relative z-10">
<div class="flex-1 overflow-y-auto">
{% block content %}{% endblock %}
</div>
</div>
{% block footer %}{% endblock %}
</div> </div>
<div class="max-w-screen-xl mx-auto"> {% include '_partials/_loading_indicator.html.twig' %}
{% block content %}{% endblock %}
{% include '_partials/_loading_indicator.html.twig' %}
</div>
<div data-iframe-size></div>
</div> </div>
<div {{ stimulus_controller('toast') }}></div>
{% endblock %} {% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="w-screen h-screen {% block background %}bg-outer bg-outer--2{% endblock %}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}>
<div class="max-w-screen-xl h-screen ml-auto flex flex-col overflow-hidden bg-inner">
<div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md">
{% block header %}
{% include '_partials/_header.html.twig' %}
{% endblock %}
</div>
<div class="flex-1 flex flex-col min-h-0 relative z-10">
{% block content %}{% endblock %}
</div>
</div>
{% include '_partials/_loading_indicator.html.twig' %}
</div>
<div {{ stimulus_controller('toast') }}></div>
{% endblock %}
-97
View File
@@ -1,97 +0,0 @@
{% extends 'layout.html.twig' %}
{% block content %}
<div class="grid md:grid-cols-2 gap-x-16 gap-y-4">
<div>
{% include '_partials/_flashes.html.twig' %}
{{ form_start(personalDataForm, { 'attr': { 'data-action': 'loading#toggle' } }) }}
<div class="pb-4">
<button type="submit" class="button bg-button">
Speichern
</button>
</div>
<h1 class="text-3xl font-semibold pb-2">
Persönliche Daten
</h1>
<div class="grid grid-cols-2 gap-x-8 gap-y-4 pb-4">
<div>
<p class="text-lg font-semibold">
Vorname
</p>
{{ personalData.firstName }}
</div>
<div>
<p class="text-lg font-semibold">
Nachname
</p>
{{ personalData.name }}
</div>
<div>
<p class="text-lg font-semibold">
Gender
</p>
{{ personalData.gender|map_gender }}
</div>
<div>
<p class="text-lg font-semibold">
Geburtsdatum
</p>
{{ personalData.dateOfBirth|date('d.m.Y') }}
</div>
<div>
<p class="text-lg font-semibold">
Nationaliät
</p>
{{ personalData.nationality|map_nationality|default('-') }}
</div>
</div>
<h2 class="text-xl font-semibold pb-2">
Anschrift
</h2>
<div class="grid grid-cols-2 gap-x-8 gap-y-4 pb-4">
<div class="col-span-2">
{{ form_row(personalDataForm.street) }}
</div>
{{ form_row(personalDataForm.postCode) }}
{{ form_row(personalDataForm.city) }}
{{ form_row(personalDataForm.country) }}
</div>
<h2 class="text-xl font-semibold pb-2">
Kontakt
</h2>
<div class="grid grid-cols-2 gap-x-8 gap-y-4 pb-4">
<div class="col-span-2">
{{ form_row(personalDataForm.email) }}
</div>
{{ form_row(personalDataForm.phone) }}
{{ form_row(personalDataForm.mobile) }}
</div>
<button type="submit" class="button bg-button">
Speichern
</button>
{{ form_rest(personalDataForm) }}
{{ form_end(personalDataForm) }}
</div>
<div>
<div id="newsletter">
<h2 class="text-2xl font-semibold pb-4">
Newsletter
</h2>
<p class="pb-4">
Du bist aktuell {% if not personalData.communication.newsletter %}<strong>nicht</strong> {% endif%} zum Newsletter
angemeldet.
</p>
<button type="button"
class="relative button bg-button bg-button--secondary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-target="#newsletter"
hx-select="#newsletter"
hx-indicator="#button-indicator"
hx-swap="innerHTML">
{% if personalData.communication.newsletter %}jetzt abmelden{% else %}jetzt anmelden{% endif %}
<span class="htmx-indicator absolute top-0 left-0 inset-0 bg-secondary/80 flex items-center justify-center" id="button-indicator">{% include '_partials/_spinner.html.twig' with { 'class': 'w-6 h-6 text-white' } %}</span>
</button>
</div>
</div>
</div>
{% endblock %}
+24 -22
View File
@@ -1,28 +1,30 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block content %} {% block content %}
<h1 class="mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
Registrierung MyE&amp;P
</h1>
{% include '_partials/_flashes.html.twig' %}
{{ form_start(form, { 'attr': { 'data-action': 'loading#toggle' } }) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 mb-4">
<div> <div>
{{ form_row(form.firstName) }} <h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
{{ form_row(form.name) }} Registrierung
{{ form_row(form.email) }} <br>
{{ form_row(form.gender) }} MyE&amp;P
</h1>
{% include '_partials/_flashes.html.twig' %}
<div class="pb-4">
{{ form_start(form, { 'attr': { 'data-action': 'loading#toggle' } }) }}
{{ form_row(form.firstName, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.name, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.gender, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary">
Jetzt registrieren
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
<a href="{{ path('app_login') }}"
class="text-white underline">
Zum Login
</a>
</div> </div>
</div> </div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2"> {% endblock %}
<button type="submit" class="button bg-button">
registrieren
</button>
<a href="{{ path('app_login') }}"
class="button bg-button bg-button--secondary">
zum Login
</a>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+29 -23
View File
@@ -1,27 +1,33 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block content %} {% block content %}
<h1 class="mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
Passwort vergessen <div>
</h1> <h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
{% include '_partials/_flashes.html.twig' %} Passwort
{{ form_start(form, { 'attr': { 'data-action': 'loading#toggle' } }) }} <br>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 mb-4"> zurücksetzen
{{ form_row(form.email) }} </h1>
{% include '_partials/_flashes.html.twig' %}
<div class="pb-4">
{{ form_start(form, { 'attr': { 'data-action': 'loading#toggle' } }) }}
{{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary">
Passwort zurücksetzen
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
<div class="flex space-x-2">
<a href="{{ path('app_login') }}"
class="text-white underline">
Zum Login
</a>
<a href="{{ path('app_registration') }}"
class="text-white underline">
Jetzt registrieren
</a>
</div>
</div>
</div> </div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4"> {% endblock %}
<button type="submit" class="button bg-button">
Passwort erneuern
</button>
<a href="{{ path('app_login') }}"
class="button bg-button bg-button--secondary">
zum Login
</a>
<a href="{{ path('app_registration') }}"
class="button bg-button bg-button--secondary">
registrieren
</a>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+52 -48
View File
@@ -1,73 +1,77 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block content %} {% block content %}
<h1 class="mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
Login MyE&amp;P
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div> <div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Login<br>MyE&amp;P
</h1>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
{% if error %} {% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %} {% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %} {% endif %}
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}> <div class="pb-4">
<div class="mb-4"> <form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
<label for="username" class="mb-1 font-semibold"> <div class="mb-4">
E-Mail: <label for="username" class="mb-1 font-semibold text-white">
</label> E-Mail:
<input type="email" </label>
id="username" <input type="email"
name="_username" id="username"
value="{{ last_username }}" name="_username"
class="form-field" value="{{ last_username }}"
required class="form-field"
autocomplete="username"> required
</div> autocomplete="username">
<div class="mb-4"> </div>
<label for="password" class="mb-1 font-semibold"> <div class="mb-4">
Passwort: <label for="password" class="mb-1 font-semibold text-white">
</label> Passwort:
<input type="password" </label>
id="password" <input type="password"
name="_password" id="password"
class="form-field" name="_password"
required class="form-field"
autocomplete="current-password"> required
</div> autocomplete="current-password">
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4"> </div>
<button type="submit" class="button bg-button"> <div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
Login <button type="submit" class="button button--primary">
</button> Login
<a href="{{ path('app_reset_password') }}" </button>
class="button bg-button bg-button--secondary"> </div>
Passwort vergessen? <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</a> </form>
<a href="{{ path('app_registration') }}" </div>
class="button bg-button bg-button--secondary"> <div class="flex space-x-2">
registrieren <a href="{{ path('app_reset_password') }}"
</a> class="text-white underline">
</div> Passwort vergessen?
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"> </a>
</form> <a href="{{ path('app_registration') }}"
class="text-white underline">
Jetzt registrieren
</a>
</div>
</div> </div>
<div> <div>
<h3 class="mb-2"> <h3 class="mb-2 text-white">
Neu! Neu!
</h3> </h3>
<p class="mb-4"> <p class="mb-4 text-white">
Melde dich jetzt bei MyEP an und du kannst jederzeit auf deine aktuellen Buchungen zugreifen und Melde dich jetzt bei MyEP an und du kannst jederzeit auf deine aktuellen Buchungen zugreifen und
diese abändern. Egal ob du einen Kurs oder eine Skipasserweiterung hinzufügen/entfernen möchtest, diese abändern. Egal ob du einen Kurs oder eine Skipasserweiterung hinzufügen/entfernen möchtest,
oder das vegetarische Essen auswählen willst. Im neuen Portal ist das nun kostenlos bis 7 Tage vor oder das vegetarische Essen auswählen willst. Im neuen Portal ist das nun kostenlos bis 7 Tage vor
Reisebeginn möglich! Reisebeginn möglich!
</p> </p>
<h3 class="mb-2"> <h3 class="mb-2 text-white">
Noch keine Zugangsdaten? Noch keine Zugangsdaten?
</h3> </h3>
<p class="mb-2"> <p class="mb-2 text-white">
Dann gib deine E-Mail ein, mit der Du gebucht hast und klicke auf "Passwort vergessen"! Und wir Dann gib deine E-Mail ein, mit der Du gebucht hast und klicke auf "Passwort vergessen"! Und wir
senden dir ein E-Mail mit einem Passwort-Link! senden dir ein E-Mail mit einem Passwort-Link!
</p> </p>
<p> <p class="text-white">
Ein LogIn ist nur möglich wenn Eure Daten schon durch eine Buchung oder Anfrage bei uns Ein LogIn ist nur möglich wenn Eure Daten schon durch eine Buchung oder Anfrage bei uns
angelegt wurden. Wenn Ihr auch Kunde werden möchtet, dann schickt uns Eure Interessen über unser angelegt wurden. Wenn Ihr auch Kunde werden möchtet, dann schickt uns Eure Interessen über unser
<a href="https://www.ep-reisen.de/skireisen/unternehmen/kontakt/kontaktformular/" title="Kontaktformular" class="underline">Kontaktformular</a>. <a href="https://www.ep-reisen.de/skireisen/unternehmen/kontakt/kontaktformular/" title="Kontaktformular" class="underline">Kontaktformular</a>.
+14 -10
View File
@@ -90,8 +90,9 @@ class ParticipantEditDtoTest extends TestCase
$this->createAdultParticipant('[email protected]'), $this->createAdultParticipant('[email protected]'),
]); ]);
// Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness
$wrapper = new ParticipantEditDto( $wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[0], participant: $bookingDto->participants[1],
bookingContext: $bookingDto, bookingContext: $bookingDto,
); );
@@ -187,8 +188,9 @@ class ParticipantEditDtoTest extends TestCase
$this->createAdultParticipant('[email protected]'), $this->createAdultParticipant('[email protected]'),
]); ]);
// Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness
$wrapper = new ParticipantEditDto( $wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[0], participant: $bookingDto->participants[1],
bookingContext: $bookingDto, bookingContext: $bookingDto,
); );
@@ -206,14 +208,15 @@ class ParticipantEditDtoTest extends TestCase
public function testWhitespaceNormalizationInEmailComparison(): void public function testWhitespaceNormalizationInEmailComparison(): void
{ {
$participant1 = $this->createAdultParticipant('[email protected]'); $participant1 = $this->createAdultParticipant('[email protected]');
$participant1->email = ' [email protected] ';
$participant2 = $this->createAdultParticipant('[email protected]'); $participant2 = $this->createAdultParticipant('[email protected]');
$participant2->email = ' [email protected] ';
$bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]);
// Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness
$wrapper = new ParticipantEditDto( $wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[0], participant: $bookingDto->participants[1],
bookingContext: $bookingDto, bookingContext: $bookingDto,
); );
@@ -236,16 +239,16 @@ class ParticipantEditDtoTest extends TestCase
$this->createAdultParticipant('[email protected]'), $this->createAdultParticipant('[email protected]'),
]); ]);
// Validate first participant - should fail // Validate first participant (applicant) - should pass (applicant is exempt from email uniqueness)
$wrapper1 = new ParticipantEditDto( $wrapper1 = new ParticipantEditDto(
participant: $bookingDto->participants[0], participant: $bookingDto->participants[0],
bookingContext: $bookingDto, bookingContext: $bookingDto,
); );
$violations1 = $this->validator->validate($wrapper1, null, ['booking_create']); $violations1 = $this->validator->validate($wrapper1, null, ['booking_create']);
$this->assertCount(1, $violations1); $this->assertCount(0, $violations1);
// Validate second participant - should fail // Validate second participant - should fail (duplicate with applicant and third participant)
$wrapper2 = new ParticipantEditDto( $wrapper2 = new ParticipantEditDto(
participant: $bookingDto->participants[1], participant: $bookingDto->participants[1],
bookingContext: $bookingDto, bookingContext: $bookingDto,
@@ -254,7 +257,7 @@ class ParticipantEditDtoTest extends TestCase
$violations2 = $this->validator->validate($wrapper2, null, ['booking_create']); $violations2 = $this->validator->validate($wrapper2, null, ['booking_create']);
$this->assertCount(1, $violations2); $this->assertCount(1, $violations2);
// Validate third participant - should fail // Validate third participant - should fail (duplicate with applicant and second participant)
$wrapper3 = new ParticipantEditDto( $wrapper3 = new ParticipantEditDto(
participant: $bookingDto->participants[2], participant: $bookingDto->participants[2],
bookingContext: $bookingDto, bookingContext: $bookingDto,
@@ -310,14 +313,15 @@ class ParticipantEditDtoTest extends TestCase
$bookingDto->participants = [$participant1, $participant2]; $bookingDto->participants = [$participant1, $participant2];
// Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness
$wrapper = new ParticipantEditDto( $wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[0], participant: $bookingDto->participants[1],
bookingContext: $bookingDto, bookingContext: $bookingDto,
); );
$violations = $this->validator->validate($wrapper, null, ['booking_edit']); $violations = $this->validator->validate($wrapper, null, ['booking_edit']);
// Should have uniqueness violation (edit mode with immutable applicant = strict validation) // Should have uniqueness violation (edit mode still validates email uniqueness for non-applicants)
$this->assertCount(1, $violations); $this->assertCount(1, $violations);
} }