diff --git a/assets/app.js b/assets/app.js index 64dd602..8943bb1 100644 --- a/assets/app.js +++ b/assets/app.js @@ -7,4 +7,5 @@ htmx.config.includeIndicatorStyles = false htmx.config.historyEnabled = false htmx.config.historyCacheSize = 0 htmx.config.allowScriptTags = false -htmx.config.withCredentials = true \ No newline at end of file +htmx.config.withCredentials = true +htmx.config.selfRequestsOnly = false \ No newline at end of file diff --git a/assets/controllers/availabilities_controller.js b/assets/controllers/availabilities_controller.js deleted file mode 100644 index e1ec9ed..0000000 --- a/assets/controllers/availabilities_controller.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Controller} from '@hotwired/stimulus' - -export default class extends Controller { - static targets = ['field'] -} \ No newline at end of file diff --git a/assets/controllers/booking_controller.js b/assets/controllers/booking_controller.js new file mode 100644 index 0000000..5ce0c6f --- /dev/null +++ b/assets/controllers/booking_controller.js @@ -0,0 +1,29 @@ +import { Controller} from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['field'] + static values = { availabilities: Object } + + toggle(event) { + let field = event.target + let id = field.value + let availabilities = this.availabilitiesValue + if (field.checked) { + availabilities[id].available-- + } else { + availabilities[id].available++ + } + field.disabled = 0 >= availabilities[id].available + field.classList.toggle('cursor-not-allowed', field.disabled) + this.availabilitiesValue = availabilities + } + + availabilitiesValueChanged(availabilities) { + this.fieldTargets.forEach((field) => { + let id = field.value + if (false === field.checked && 0 >= availabilities[id].available) { + field.disabled = true + } + }) + } +} \ No newline at end of file diff --git a/assets/controllers/toast_controller.js b/assets/controllers/toast_controller.js new file mode 100644 index 0000000..f661e60 --- /dev/null +++ b/assets/controllers/toast_controller.js @@ -0,0 +1,18 @@ +import { Controller } from '@hotwired/stimulus' +import Toastify from 'toastify-js' + +export default class extends Controller { + + static values = { text: String, class: String } + + connect() { + Toastify({ + duration: 5000, + text: this.textValue, + gravity: 'top', + position: 'right', + className: this.classValue, + close: true, + }).showToast() + } +} \ No newline at end of file diff --git a/assets/controllers/tooltip_controller.js b/assets/controllers/tooltip_controller.js new file mode 100644 index 0000000..a95ecd7 --- /dev/null +++ b/assets/controllers/tooltip_controller.js @@ -0,0 +1,36 @@ +import { Controller } from '@hotwired/stimulus' +import tippy from 'tippy.js' + +export default class extends Controller { + static targets = ['trigger', 'template'] + static values = { content: String, placement: String } + + connect() { + let options, element + let placement = this.placementValue ? this.placementValue : 'top' + if (this.hasTemplateTarget) { + options = { + content: this.templateTarget.innerHTML, + placement, + touch: true, + trigger: 'click', + allowHTML: true, + interactive: true, + } + } else { + options = { + content: this.contentValue, + placement, + touch: false, + allowHTML: true, + interactive: true, + } + } + if (this.hasTriggerTarget) { + element = this.triggerTarget + } else { + element = this.element + } + tippy(element, options) + } +} diff --git a/assets/styles/_components.css b/assets/styles/_components.css index 6daa433..e1324f9 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,5 +1,5 @@ @import "components/forms.css"; @import "components/button.css"; @import "components/menu.css"; -/*@import "components/toast.css";*/ -/*@import "components/tooltip.css";*/ \ No newline at end of file +@import "components/toast.css"; +@import "components/tooltip.css"; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3170dea..b22bdeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,10 @@ "": { "name": "myep-next", "license": "WTFPL", + "dependencies": { + "tippy.js": "^6.3.7", + "toastify-js": "^1.12.0" + }, "devDependencies": { "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", @@ -1807,6 +1811,15 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -6485,6 +6498,14 @@ "node": ">=0.8" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -6506,6 +6527,11 @@ "node": ">=8.0" } }, + "node_modules/toastify-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", + "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 17bfdc3..da7182b 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,9 @@ "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress" + }, + "dependencies": { + "tippy.js": "^6.3.7", + "toastify-js": "^1.12.0" } } diff --git a/src/BusProNet/ApiResponseParser/CrmAttributesResponseParser.php b/src/BusProNet/ApiResponseParser/CrmAttributesResponseParser.php index 396a6e5..3e69954 100644 --- a/src/BusProNet/ApiResponseParser/CrmAttributesResponseParser.php +++ b/src/BusProNet/ApiResponseParser/CrmAttributesResponseParser.php @@ -2,6 +2,7 @@ namespace App\BusProNet\ApiResponseParser; +use App\BusProNet\Model\CrmAction; use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\CrmSelection; use App\BusProNet\Model\CrmSelectionGroup; @@ -18,11 +19,13 @@ class CrmAttributesResponseParser public function parse(\SimpleXMLElement $xml): CrmAttributes { $groups = []; + $actions = []; $isAdmin = $isManager = $isHouseManager = $isTeamer = false; $hotelCode = null; foreach ($xml->selektionsmerkmale->selektionsgruppe as $item) { $group = new CrmSelectionGroup(); + $group->id = (int) $item->attributes()['id']; $group->label = (string) $item->attributes()['bezeichnung']; $attributes = []; @@ -32,6 +35,7 @@ class CrmAttributesResponseParser $attribute = new CrmSelection(); $attribute->id = (int) $subItemAttributes['id']; $attribute->label = $attributeLabel; + $attribute->mutable = $this->stringToBool((string) $subItemAttributes['aenderbar']); $attribute->selected = $this->stringToBool((string) $subItemAttributes['auswahl']); if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attributeLabel, $matches) && true === $attribute->selected) { @@ -54,8 +58,21 @@ class CrmAttributesResponseParser $groups[] = $group; } + foreach ($xml->crmaktionen->crmaktion as $item) { + $attributes = $item->attributes(); + $crmAction = new CrmAction(); + $crmAction->id = (int) $attributes['id']; + $crmAction->code = (string) $attributes['code']; + $crmAction->label = (string) $attributes['bezeichnung']; + $crmAction->mutable = $this->stringToBool((string) $attributes['aenderbar']); + $crmAction->selected = $this->stringToBool((string) $attributes['auswahl']); + + $actions[] = $crmAction; + } + $response = new CrmAttributes(); $response->selectionGroups = $groups; + $response->crmActions = $actions; $response->admin = $isAdmin; $response->manager = $isManager; $response->houseManager = $isHouseManager; @@ -64,4 +81,4 @@ class CrmAttributesResponseParser return $response; } -} \ No newline at end of file +} diff --git a/src/BusProNet/Model/Availability.php b/src/BusProNet/Model/Availability.php index 239c4d5..c221cc9 100644 --- a/src/BusProNet/Model/Availability.php +++ b/src/BusProNet/Model/Availability.php @@ -2,10 +2,18 @@ namespace App\BusProNet\Model; -class Availability +class Availability implements \JsonSerializable { public ?int $serviceId = null; public ?string $status = null; public ?int $available = null; public ?float $price = null; + + public function jsonSerialize(): array + { + return [ + 'id' => $this->serviceId, + 'available' => $this->available, + ]; + } } \ No newline at end of file diff --git a/src/BusProNet/Model/CrmAction.php b/src/BusProNet/Model/CrmAction.php new file mode 100644 index 0000000..9f7bc98 --- /dev/null +++ b/src/BusProNet/Model/CrmAction.php @@ -0,0 +1,12 @@ +selectionGroups as $group) { - /** @var CrmSelectionGroup $group */ - if (false === isset($crmSelections[$group->label])) { - $crmSelections[$group->label] = []; - } - foreach ($group->selections as $attribute) { - /** @var CrmSelection $attribute */ - if (false === $attribute->selected) { - continue; - } - $crmSelections[$group->label][] = $attribute->label; - } - } - - return $crmSelections; - } } \ No newline at end of file diff --git a/src/BusProNet/Model/CrmSelection.php b/src/BusProNet/Model/CrmSelection.php index 8ecbb4c..2d9642f 100644 --- a/src/BusProNet/Model/CrmSelection.php +++ b/src/BusProNet/Model/CrmSelection.php @@ -6,5 +6,6 @@ class CrmSelection { public ?int $id = null; public ?string $label = null; + public bool $mutable = false; public bool $selected = false; } \ No newline at end of file diff --git a/src/BusProNet/Model/CrmSelectionGroup.php b/src/BusProNet/Model/CrmSelectionGroup.php index 3756ec1..10c986b 100644 --- a/src/BusProNet/Model/CrmSelectionGroup.php +++ b/src/BusProNet/Model/CrmSelectionGroup.php @@ -19,4 +19,16 @@ class CrmSelectionGroup public ?int $id = null; public ?string $label = null; public ?array $selections = null; + + public function getMutableSelections(): array + { + return array_filter($this->selections, function (CrmSelection $selection) { + return $selection->mutable; + }); + } + + public function isVisible(): bool + { + return 0 < count($this->getMutableSelections()); + } } \ No newline at end of file diff --git a/src/Controller/PersonalDataController.php b/src/Controller/PersonalDataController.php index db5d009..40894ae 100644 --- a/src/Controller/PersonalDataController.php +++ b/src/Controller/PersonalDataController.php @@ -32,14 +32,14 @@ class PersonalDataController extends AbstractController ->getPersonalData($bpnUser->getEmail(), $bpnUser->getPassword()) ; - $form = $this->createForm(PersonalDataType::class, $personalData, [ + $personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [ 'attr' => ['novalidate' => 'novalidate'], 'hx_post' => $this->generateUrl('app_personal_data'), 'hx_target' => '#app', ]); - $form->handleRequest($request); + $personalDataForm->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { + if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) { try { $this->apiClient->updatePersonalData($bpnUser->getEmail(), $bpnUser->getPassword(), $personalData); $this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert'); @@ -52,7 +52,7 @@ class PersonalDataController extends AbstractController return $this->render('personal_data/index.html.twig', [ 'personalData' => $personalData, - 'form' => $form->createView(), + 'personalDataForm' => $personalDataForm->createView(), ]); } diff --git a/src/Form/Model/ParticipantData.php b/src/Form/Model/ParticipantData.php index b7f43f7..13ac0ff 100644 --- a/src/Form/Model/ParticipantData.php +++ b/src/Form/Model/ParticipantData.php @@ -24,10 +24,10 @@ class ParticipantData public ?string $gender = null; public ?string $nationality = null; - #[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }}cm angeben', min: 140, max: 210)] + #[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }}cm angeben', min: 150, max: 210)] public ?string $height = null; - #[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }} angeben', min: 35, max: 49)] + #[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }} angeben', min: 37, max: 49)] public ?string $shoeSize = null; #[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }}kg angeben', min: 40, max: 130)] diff --git a/src/Form/ParticipantType.php b/src/Form/ParticipantType.php index ed1ffc5..f2a9205 100644 --- a/src/Form/ParticipantType.php +++ b/src/Form/ParticipantType.php @@ -21,55 +21,53 @@ class ParticipantType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - if (true === $options['personal_data_mutable']) { - $builder - ->add('firstName', TextType::class, [ - 'label' => 'Vorname', - ]) - ->add('lastName', TextType::class, [ - 'label' => 'Nachname', - ]) - ->add('dateOfBirth', BirthdayType::class, [ - 'label' => 'Geburtsdatum', - 'html5' => true, - 'widget' => 'single_text', - 'input' => 'datetime_immutable', - ]) - ->add('gender', ChoiceType::class, [ - 'label' => 'Geschlecht', - 'required' => false, - 'placeholder' => 'keine Angabe', - 'choices' => [ - 'männlich' => 'M', - 'weiblich' => 'W', - 'divers' => 'D', - ], - ]) - ->add('nationality', CountryType::class, [ - 'label' => 'Nationalität', - 'property' => 'nationality', - 'preferred_choices' => ['D', 'A', 'CH'], - ]) - ->add('email', EmailType::class, [ - 'label' => 'E-Mail', - ]) - ->add('mobile', TextType::class, [ - 'label' => 'Telefon (mobil)', - ]) - ->add('height', IntegerType::class, [ - 'label' => 'Körpergröße [cm]', - 'required' => false, - ]) - ->add('shoeSize', IntegerType::class, [ - 'label' => 'Schuhgröße', - 'required' => false, - ]) - ->add('weight', IntegerType::class, [ - 'label' => 'Gewicht [kg]', - 'required' => false, - ]) - ; - } + $builder + ->add('firstName', TextType::class, [ + 'label' => 'Vorname', + ]) + ->add('lastName', TextType::class, [ + 'label' => 'Nachname', + ]) + ->add('dateOfBirth', BirthdayType::class, [ + 'label' => 'Geburtsdatum', + 'html5' => true, + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + ]) + ->add('gender', ChoiceType::class, [ + 'label' => 'Geschlecht', + 'required' => false, + 'placeholder' => 'keine Angabe', + 'choices' => [ + 'männlich' => 'M', + 'weiblich' => 'W', + 'divers' => 'D', + ], + ]) + ->add('nationality', CountryType::class, [ + 'label' => 'Nationalität', + 'property' => 'nationality', + 'preferred_choices' => ['D', 'A', 'CH'], + ]) + ->add('email', EmailType::class, [ + 'label' => 'E-Mail', + ]) + ->add('mobile', TextType::class, [ + 'label' => 'Telefon (mobil)', + ]) + ->add('height', IntegerType::class, [ + 'label' => 'Körpergröße [cm]', + 'required' => false, + ]) + ->add('shoeSize', IntegerType::class, [ + 'label' => 'Schuhgröße', + 'required' => false, + ]) + ->add('weight', IntegerType::class, [ + 'label' => 'Gewicht [kg]', + 'required' => false, + ]) + ; $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { /** @var ParticipantData $participantData */ @@ -99,8 +97,8 @@ class ParticipantType extends AbstractType }, 'choice_attr' => function (?Service $service) { $attributes = [ - 'data-available' => $service->available, - 'data-availabilities-target' => 'field', + 'data-booking-target' => 'field', + 'data-action' => 'booking#toggle', ]; // forcibly select mandatory services @@ -131,96 +129,93 @@ class ParticipantType extends AbstractType ]; // Additional services - if (true === $options['additional_services_mutable']) { - if (0 < count($options['selectable_courses'])) { - $form->add('courses', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Kurse', - 'choices' => $options['selectable_courses'], - ]); - } - if (0 < count($options['selectable_services'])) { - $form->add('additionalServices', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Zusatzleistungen', - 'choices' => $options['selectable_services'], - ]); - } - if (0 < count($options['selectable_ski_passes'])) { - $form->add('skiPass', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Skipass', - 'choices' => $options['selectable_ski_passes'], - ]); - } - if (0 < count($options['selectable_board'])) { - $form->add('board', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Verpflegung', - 'choices' => $options['selectable_board'], - ]); - } - if (0 < count($options['selectable_rentals'])) { - $form->add('rentals', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Verleih', - 'choices' => $options['selectable_rentals'], - ]); - } + if (0 < count($options['selectable_courses'])) { + $form->add('courses', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Kurse', + 'choices' => $options['selectable_courses'], + ]); + } + if (0 < count($options['selectable_services'])) { + $form->add('additionalServices', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Zusatzleistungen', + 'choices' => $options['selectable_services'], + ]); + } + if (0 < count($options['selectable_ski_passes'])) { + $form->add('skiPass', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Skipass', + 'choices' => $options['selectable_ski_passes'], + ]); + } + if (0 < count($options['selectable_board'])) { + $form->add('board', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Verpflegung', + 'choices' => $options['selectable_board'], + ]); + } + if (0 < count($options['selectable_rentals'])) { + $form->add('rentals', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Verleih', + 'choices' => $options['selectable_rentals'], + ]); } // Transportation - if (true === $options['transportation_services_mutable']) { - $form - ->add('transportationServiceTo', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Anreise', - 'multiple' => false, - 'choices' => $options['selectable_transportation_services_to'], - 'choice_attr' => function (?Service $service) { - return [ - 'data-available' => $service->available, - 'data-availabilities-target' => 'field', - 'data-action' => 'select-toggle#toggle', - 'data-select-toggle-value' => $service->subType, - ]; - }, - ]) - ->add('transportationServiceFro', ChoiceType::class, [ - ...$commonChoiceFieldOptions, - 'label' => 'Rückreise', - 'multiple' => false, - 'choices' => $options['selectable_transportation_services_fro'], - ]); - } + $form + ->add('transportationServiceTo', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Anreise', + 'required' => true, + 'multiple' => false, + 'choices' => $options['selectable_transportation_services_to'], + 'choice_attr' => function (?Service $service) { + return [ + 'data-booking-target' => 'field', + 'data-action' => 'select-toggle#toggle booking#toggle', + 'data-select-toggle-value' => $service->subType, + ]; + }, + ]) + ->add('transportationServiceFro', ChoiceType::class, [ + ...$commonChoiceFieldOptions, + 'label' => 'Rückreise', + 'required' => true, + 'multiple' => false, + 'choices' => $options['selectable_transportation_services_fro'], + ]) + ; // Pickup - if (true === $options['pickups_mutable']) { - $form - ->add('pickup', ChoiceType::class, [ - 'label' => 'Zustieg', - 'multiple' => false, - 'expanded' => false, - 'choices' => $options['selectable_pickups'], - 'choice_value' => 'id', - 'choice_label' => function (?Pickup $pickup) { - if (null === $pickup) { - return null; - } + $form + ->add('pickup', ChoiceType::class, [ + 'label' => 'Zustieg', + 'multiple' => false, + 'expanded' => false, + 'choices' => $options['selectable_pickups'], + 'choice_value' => 'id', + 'choice_label' => function (?Pickup $pickup) { + if (null === $pickup) { + return null; + } - $price = $pickup->price; + $price = $pickup->price; - if (null === $price || 0.0 === $price) { - return $pickup->city; - } + if (null === $price || 0.0 === $price) { + return $pickup->city; + } - return sprintf('%s (%s €)', - $pickup->city, - number_format($price, 2, ',', '.') - ); - }, - ]); - } + return sprintf('%s (%s €)', + $pickup->city, + number_format($price, 2, ',', '.') + ); + }, + ]) + ; }) ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) { $data = $event->getData(); diff --git a/templates/_partials/_flashes.html.twig b/templates/_partials/_flashes.html.twig index 69a2c83..ed4261b 100644 --- a/templates/_partials/_flashes.html.twig +++ b/templates/_partials/_flashes.html.twig @@ -1,9 +1,9 @@ {% if app.session.flashBag.peek('error')|length > 0 %} - {% include '_partials/_alert.html.twig' with { level: 'warning', messages: app.flashes('error') } %} + {% include '_partials/_toast.html.twig' with { level: 'warning', messages: app.flashes('error') } %} {% endif %} {% if app.session.flashBag.peek('success')|length > 0 %} - {% include '_partials/_alert.html.twig' with { level: 'success', messages: app.flashes('success') } %} + {% include '_partials/_toast.html.twig' with { level: 'success', messages: app.flashes('success') } %} {% endif %} {% if app.session.flashBag.peek('info')|length > 0 %} - {% include '_partials/_alert.html.twig' with { level: 'info', messages: app.flashes('info') } %} + {% include '_partials/_toast.html.twig' with { level: 'info', messages: app.flashes('info') } %} {% endif %} diff --git a/templates/_partials/_toast.html.twig b/templates/_partials/_toast.html.twig new file mode 100644 index 0000000..4c32044 --- /dev/null +++ b/templates/_partials/_toast.html.twig @@ -0,0 +1,8 @@ +{% for message in messages %} + {% if message is iterable %} + {% set message = message.id | trans(message.parameters | default({}), message.domain | default(null), message.locale | default(null)) | raw %} + {% else %} + {% set message = message | trans | raw %} + {% endif %} +
+{% endfor %} diff --git a/templates/base.html.twig b/templates/base.html.twig index b53deb8..b9a727f 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -12,6 +12,5 @@ {% block body %}{% endblock %} - {% include '_partials/_flashes.html.twig' %}