From 12760844e2f537034917c6eaa8139868337660df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 13 Jul 2026 14:14:34 +0200 Subject: [PATCH] wip: group inquiries and booking --- assets/controllers/gallery_controller.js | 29 ++ assets/styles/_components.css | 2 + assets/styles/components/gallery.css | 1 + assets/styles/components/rte-content.css | 13 + package-lock.json | 7 + package.json | 5 +- .../AccommodationBooking/IndexController.php | 4 +- .../AbstractAccommodationController.php | 1 + src/Controller/Groups/OfferController.php | 92 +++- src/Controller/Groups/Step3Controller.php | 80 +-- src/Controller/Groups/Step4Controller.php | 150 ++++++ ... AccommodationBookingConfirmationType.php} | 9 +- .../AccommodationInquiryConfirmationType.php | 21 + src/Form/OfferAcceptConfirmationType.php | 41 ++ .../AccommodationBookingLinkSigner.php | 35 ++ src/Service/AccommodationBookingService.php | 20 + src/Twig/AppRuntime.php | 5 + .../accommodation_booking/edit.html.twig | 5 + .../accommodation_booking/show.html.twig | 4 + ..._offer_accept_confirmation_modal.html.twig | 31 ++ .../groups/booking/_offer_summary.html.twig | 168 ++++++ .../groups/booking/_pagination.html.twig | 3 +- ...tep4_booking_confirmation_modal.html.twig} | 11 +- ...step4_inquiry_confirmation_modal.html.twig | 18 + .../_step4_personal_data_recap.html.twig | 31 ++ .../booking/_step4_services_recap.html.twig | 39 ++ templates/groups/booking/offer.html.twig | 189 ++++--- templates/groups/booking/step_3.html.twig | 9 +- templates/groups/booking/step_4.html.twig | 74 +++ templates/htmx_modal.html.twig | 6 +- .../Accommodation/Step3ControllerTest.php | 184 ++----- .../Controller/Groups/OfferControllerTest.php | 484 +++++++++++++----- .../Controller/Groups/Step4ControllerTest.php | 402 +++++++++++++++ .../AccommodationBookingLinkSignerTest.php | 73 +++ 34 files changed, 1810 insertions(+), 436 deletions(-) create mode 100644 assets/controllers/gallery_controller.js create mode 100644 assets/styles/components/gallery.css create mode 100644 assets/styles/components/rte-content.css create mode 100644 src/Controller/Groups/Step4Controller.php rename src/Form/{AccommodationStep3ConfirmationType.php => AccommodationBookingConfirmationType.php} (76%) create mode 100644 src/Form/AccommodationInquiryConfirmationType.php create mode 100644 src/Form/OfferAcceptConfirmationType.php create mode 100644 templates/groups/booking/_offer_accept_confirmation_modal.html.twig create mode 100644 templates/groups/booking/_offer_summary.html.twig rename templates/groups/booking/{_step3_confirmation_modal.html.twig => _step4_booking_confirmation_modal.html.twig} (77%) create mode 100644 templates/groups/booking/_step4_inquiry_confirmation_modal.html.twig create mode 100644 templates/groups/booking/_step4_personal_data_recap.html.twig create mode 100644 templates/groups/booking/_step4_services_recap.html.twig create mode 100644 templates/groups/booking/step_4.html.twig create mode 100644 tests/Controller/Groups/Step4ControllerTest.php diff --git a/assets/controllers/gallery_controller.js b/assets/controllers/gallery_controller.js new file mode 100644 index 00000000..8525658e --- /dev/null +++ b/assets/controllers/gallery_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus' +import GLightbox from 'glightbox' + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = ['item'] + + connect() { + this.lightbox = GLightbox({ + elements: this.itemTargets.map((el) => ({ + href: el.dataset.galleryHref, + type: 'image', + alt: el.dataset.galleryAlt ?? '', + })), + }) + + this.itemTargets.forEach((el, index) => { + el.addEventListener('click', (e) => { + e.preventDefault() + this.lightbox.openAt(index) + }) + }) + } + + disconnect() { + this.lightbox.destroy() + } +} diff --git a/assets/styles/_components.css b/assets/styles/_components.css index 5f1f0273..4e0898f5 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,7 +1,9 @@ @import "components/button.css"; @import "components/forms.css"; +@import "components/gallery.css"; @import "components/pagination.css"; @import "components/price_calendar.css"; @import "components/toast.css"; @import "components/tooltip.css"; @import "components/typography.css"; +@import "components/rte-content.css"; diff --git a/assets/styles/components/gallery.css b/assets/styles/components/gallery.css new file mode 100644 index 00000000..bc0fd92c --- /dev/null +++ b/assets/styles/components/gallery.css @@ -0,0 +1 @@ +@import "glightbox/dist/css/glightbox.min.css"; diff --git a/assets/styles/components/rte-content.css b/assets/styles/components/rte-content.css new file mode 100644 index 00000000..9df8be97 --- /dev/null +++ b/assets/styles/components/rte-content.css @@ -0,0 +1,13 @@ +.rte-content { + p { + @apply pb-4; + } + + ul { + @apply list-disc p-4; + } + + ol { + @apply list-decimal p-4; + } +} diff --git a/package-lock.json b/package-lock.json index acc03e33..602aa9cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "license": "WTFPL", "dependencies": { "@trevoreyre/autocomplete-js": "^3.0.3", + "glightbox": "^3.3.1", "sortablejs": "^1.15.7", "stimulus-use": "^0.52.3", "vanilla-calendar-pro": "^3.1.0" @@ -4563,6 +4564,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/glightbox": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/glightbox/-/glightbox-3.3.1.tgz", + "integrity": "sha512-nXoKfJRnQTDaAFAw5799hjpfAAHx5aLvOLG0SIGudeMCwtHgO3P2/avNYapJ+SL4UEZxO2YNBPtq6pzMhSx42g==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", diff --git a/package.json b/package.json index 9fa01098..4f5d4246 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,9 @@ }, "dependencies": { "@trevoreyre/autocomplete-js": "^3.0.3", - "vanilla-calendar-pro": "^3.1.0", + "glightbox": "^3.3.1", "sortablejs": "^1.15.7", - "stimulus-use": "^0.52.3" + "stimulus-use": "^0.52.3", + "vanilla-calendar-pro": "^3.1.0" } } diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php index bc7048df..a6abfc3a 100644 --- a/src/Controller/Admin/AccommodationBooking/IndexController.php +++ b/src/Controller/Admin/AccommodationBooking/IndexController.php @@ -38,8 +38,8 @@ class IndexController extends AbstractController $request->query->getInt('page', 1), $request->query->getInt('limit', 20), [ - 'defaultSortFieldName' => 'booking.dateFrom', - 'defaultSortDirection' => 'ASC', + 'defaultSortFieldName' => 'booking.createdAt', + 'defaultSortDirection' => 'DESC', ] ); diff --git a/src/Controller/Groups/AbstractAccommodationController.php b/src/Controller/Groups/AbstractAccommodationController.php index bfdc5472..f8f01681 100644 --- a/src/Controller/Groups/AbstractAccommodationController.php +++ b/src/Controller/Groups/AbstractAccommodationController.php @@ -43,6 +43,7 @@ abstract class AbstractAccommodationController extends AbstractController $route = match ($dto->currentStep) { 2 => 'app_groups_booking_step_2', 3 => 'app_groups_booking_step_3', + 4 => 'app_groups_booking_step_4', default => 'app_groups_booking_step_1', }; diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index 0684d539..7cbb638e 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace App\Controller\Groups; +use App\Entity\Groups\AccommodationBooking; +use App\Form\OfferAcceptConfirmationType; +use App\Htmx\HxTrait; +use App\Model\AccommodationBookingContext; use App\Repository\Groups\AccommodationBookingRepository; use App\Service\AccommodationBookingBreakdownCalculator; use App\Service\AccommodationBookingLinkSigner; @@ -12,10 +16,13 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Routing\Attribute\Route; class OfferController extends AbstractController { + use HxTrait; + public function __construct( private readonly AccommodationBookingRepository $bookingRepository, private readonly AccommodationBookingLinkSigner $linkSigner, @@ -24,8 +31,13 @@ class OfferController extends AbstractController ) { } - #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET', 'POST'])] - public function index(string $uuid, Request $request): Response + /** + * Signed-link entry point: validates the `t`/`_hash` query params once, + * authorizes the session for this booking, and redirects to the plain + * (session-gated) offer page — nothing downstream needs the signature again. + */ + #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET'])] + public function access(string $uuid, Request $request): Response { $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); @@ -33,19 +45,81 @@ class OfferController extends AbstractController return $this->render('groups/booking/offer_unavailable.html.twig'); } - if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) { - if (!$this->isCsrfTokenValid('accept_accommodation_offer_'.$booking->getUuid(), $request->request->getString('_token'))) { - throw $this->createAccessDeniedException('Invalid CSRF token.'); - } + $this->linkSigner->authorizeSession($request, $booking); - $this->bookingService->acceptBooking($booking); + return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid])); + } - return new RedirectResponse($request->getUri()); + #[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_booking_offer_view', methods: ['GET'])] + public function view(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); } + $accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.'); + $priceBreakdown = $this->breakdownCalculator->compute($booking); + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + priceBreakdown: $priceBreakdown, + ); + + $session = $request->getSession(); + $justAccepted = $session instanceof FlashBagAwareSessionInterface + && [] !== $session->getFlashBag()->get('accommodation_offer_accepted'); + return $this->render('groups/booking/offer.html.twig', [ 'booking' => $booking, - 'priceBreakdown' => $this->breakdownCalculator->compute($booking), + 'priceBreakdown' => $priceBreakdown, + 'ctx' => $ctx, + 'justAccepted' => $justAccepted, ]); } + + #[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_booking_offer_confirm', methods: ['GET', 'POST'])] + public function confirm(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->redirectToOfferPage($request, $uuid); + } + + if (!$booking->isInquiry()) { + return $this->redirectToOfferPage($request, $uuid); + } + + $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $this->bookingService->acceptBooking($booking); + $this->addFlash('accommodation_offer_accepted', true); + + return $this->redirectToOfferPage($request, $uuid); + } + + return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [ + 'booking' => $booking, + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Sends the browser to a full reload of the (non-modal) offer page — this is + * triggered from an htmx-loaded modal, so a plain render/redirect here would + * get appended as an inert HTML fragment instead of actually navigating. + */ + private function redirectToOfferPage(Request $request, string $uuid): Response + { + $url = $this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]); + + return $this->htmxRedirect($request, $url); + } } diff --git a/src/Controller/Groups/Step3Controller.php b/src/Controller/Groups/Step3Controller.php index 9f3dd6f6..0a5cee23 100644 --- a/src/Controller/Groups/Step3Controller.php +++ b/src/Controller/Groups/Step3Controller.php @@ -6,22 +6,17 @@ namespace App\Controller\Groups; use App\Entity\Groups\Accommodation; use App\Exception\AccommodationSessionNotFoundException; -use App\Form\AccommodationStep3ConfirmationType; use App\Form\AccommodationStep3Type; use App\Form\Model\AccommodationBookingDto; -use App\Htmx\HxTrait; use App\Model\AccommodationBookingContext; use App\Service\AccommodationBookingService; use App\Service\AccommodationSessionManager; -use Symfony\Component\Form\ClickableInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class Step3Controller extends AbstractAccommodationController { - use HxTrait; - public function __construct( private readonly AccommodationBookingService $bookingService, private readonly AccommodationSessionManager $sessionManager, @@ -31,7 +26,7 @@ class Step3Controller extends AbstractAccommodationController #[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')] public function index(Request $request): Response { - $dto = $this->resolveDto($request, false); + $dto = $this->resolveDto($request); if ($dto instanceof Response) { return $dto; } @@ -51,33 +46,10 @@ class Step3Controller extends AbstractAccommodationController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $submitBookingClicked = false; - // booking or inquiry status depends on clicked button - if (true === $form->has('submitBooking')) { - $submitBookingButton = $form->get('submitBooking'); - $submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked(); - } + $dto->currentStep = 4; + $this->sessionManager->save($request, $dto); - // Booking is only offered via the confirmation modal; a forced-inquiry DTO - // can never actually reach this branch since submitBooking is disabled for it. - if (true === $submitBookingClicked && false === $dto->isInquiry) { - $dto->forceInquiry = false; - $this->sessionManager->save($request, $dto); - - $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); - - return $this->render('groups/booking/step_3.html.twig', [ - 'dto' => $dto, - 'ctx' => $ctx, - 'form' => $form, - 'confirmationForm' => $confirmationForm, - ]); - } - - $dto->forceInquiry = true; - $this->persistBooking($request, $dto, $accommodation, $services); - - return $this->redirectToRoute('app_groups_booking_success'); + return $this->redirectToRoute('app_groups_booking_step_4'); } return $this->render('groups/booking/step_3.html.twig', [ @@ -87,41 +59,16 @@ class Step3Controller extends AbstractAccommodationController ]); } - #[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])] - public function confirm(Request $request): Response - { - $dto = $this->resolveDto($request, true); - if ($dto instanceof Response) { - return $dto; - } - - $accommodation = $this->loadAccommodationOrFail($dto); - - $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); - $confirmationForm->handleRequest($request); - - if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) { - $services = $this->bookingService->loadAvailableServices($dto, $accommodation); - $this->persistBooking($request, $dto, $accommodation, $services); - - return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); - } - - return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [ - 'confirmationForm' => $confirmationForm, - ]); - } - /** * Loads the session DTO and checks step access, returning an early * response (session failure or step-access redirect) if either fails. */ - private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response + private function resolveDto(Request $request): AccommodationBookingDto|Response { try { $dto = $this->sessionManager->getOrFail($request); } catch (AccommodationSessionNotFoundException $e) { - return $this->createFailureResponse($e, $htmx); + return $this->createFailureResponse($e, false); } if ($redirect = $this->validateStepAccess($dto, 3)) { @@ -140,19 +87,4 @@ class Step3Controller extends AbstractAccommodationController return $accommodation; } - - /** - * @param array{boardServices: array, additionalServices: array} $services - */ - private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void - { - $prices = $this->bookingService->loadPrices($dto, $accommodation); - - $booking = $this->bookingService->persist($dto, $accommodation, $prices, $services); - $this->bookingService->issueAccessLinkForDirectBooking($booking); - $this->bookingService->sendNotificationEmail($booking); - $this->bookingService->sendCustomerConfirmationEmail($booking); - $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); - $this->sessionManager->clear($request); - } } diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php new file mode 100644 index 00000000..111bd29e --- /dev/null +++ b/src/Controller/Groups/Step4Controller.php @@ -0,0 +1,150 @@ +resolveDto($request, false); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + + if ($request->isMethod(Request::METHOD_POST)) { + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + $inquiryForm->handleRequest($request); + + if (!$inquiryForm->isSubmitted() || !$inquiryForm->isValid()) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $dto->forceInquiry = true; + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->redirectToRoute('app_groups_booking_success'); + } + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + boardServices: $services['boardServices'], + additionalServices: $services['additionalServices'], + priceBreakdown: $dto->priceBreakdown ?: null, + ); + + return $this->render('groups/booking/step_4.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + ]); + } + + #[Route('/groups/booking/step-4/confirm-inquiry', name: 'app_groups_booking_step_4_confirm_inquiry', methods: ['GET'])] + public function confirmInquiry(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + + return $this->render('groups/booking/_step4_inquiry_confirmation_modal.html.twig', [ + 'inquiryForm' => $inquiryForm, + ]); + } + + #[Route('/groups/booking/step-4/confirm', name: 'app_groups_booking_step_4_confirm', methods: ['GET', 'POST'])] + public function confirm(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + + $confirmationForm = $this->createForm(AccommodationBookingConfirmationType::class, $dto, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); + } + + return $this->render('groups/booking/_step4_booking_confirmation_modal.html.twig', [ + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Loads the session DTO and checks step access, returning an early + * response (session failure or step-access redirect) if either fails. + */ + private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, $htmx); + } + + if ($redirect = $this->validateStepAccess($dto, 4)) { + return $redirect; + } + + return $dto; + } + + private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation + { + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + + return $accommodation; + } + + /** + * @param array{boardServices: array, additionalServices: array} $services + */ + private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void + { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services); + $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); + $this->sessionManager->clear($request); + } +} diff --git a/src/Form/AccommodationStep3ConfirmationType.php b/src/Form/AccommodationBookingConfirmationType.php similarity index 76% rename from src/Form/AccommodationStep3ConfirmationType.php rename to src/Form/AccommodationBookingConfirmationType.php index b22035cf..3a1fab08 100644 --- a/src/Form/AccommodationStep3ConfirmationType.php +++ b/src/Form/AccommodationBookingConfirmationType.php @@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints\IsTrue; /** * @extends AbstractType */ -class AccommodationStep3ConfirmationType extends AbstractType +class AccommodationBookingConfirmationType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { @@ -24,12 +24,12 @@ class AccommodationStep3ConfirmationType extends AbstractType ); $builder->add('termsAccepted', CheckboxType::class, [ - 'label' => sprintf('Durch Anklicken des Absenden-Buttons bestätige ich, dass ich die %s gelesen habe und damit einverstanden bin, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), 'label_html' => true, 'mapped' => false, 'required' => true, 'constraints' => [ - new IsTrue(message: 'Bitte akzeptiere die AGB, um fortzufahren.'), + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), ], ]); } @@ -38,7 +38,8 @@ class AccommodationStep3ConfirmationType extends AbstractType { $resolver->setDefaults([ 'data_class' => AccommodationBookingDto::class, - 'terms_url' => 'https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/', ]); + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); } } diff --git a/src/Form/AccommodationInquiryConfirmationType.php b/src/Form/AccommodationInquiryConfirmationType.php new file mode 100644 index 00000000..9a4a3f8c --- /dev/null +++ b/src/Form/AccommodationInquiryConfirmationType.php @@ -0,0 +1,21 @@ + + */ +class AccommodationInquiryConfirmationType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_token_id' => 'groups_booking_step4_inquiry', + ]); + } +} diff --git a/src/Form/OfferAcceptConfirmationType.php b/src/Form/OfferAcceptConfirmationType.php new file mode 100644 index 00000000..fefcf339 --- /dev/null +++ b/src/Form/OfferAcceptConfirmationType.php @@ -0,0 +1,41 @@ + + */ +class OfferAcceptConfirmationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $termsLink = sprintf( + 'allgemeinen Geschäftsbedingungen (AGB)', + htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8') + ); + + $builder->add('termsAccepted', CheckboxType::class, [ + 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); + } +} diff --git a/src/Service/AccommodationBookingLinkSigner.php b/src/Service/AccommodationBookingLinkSigner.php index b9ee6a82..8b608cad 100644 --- a/src/Service/AccommodationBookingLinkSigner.php +++ b/src/Service/AccommodationBookingLinkSigner.php @@ -69,4 +69,39 @@ class AccommodationBookingLinkSigner return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; } + + /** + * Marks the current session as authorized to view/act on this booking's offer. + * Called once, after a successful {@see isValidLinkRequest()} check at the + * signed-link entry point — every other route in this flow then trusts the + * session instead of re-deriving cryptographic validity from its own URL + * (which wouldn't work anyway, since the signature is bound to the exact + * URI it was generated for). + */ + public function authorizeSession(Request $request, AccommodationBooking $booking): void + { + $request->getSession()->set($this->sessionKey($booking), $booking->getAccessLinkIssuedAt()?->getTimestamp()); + } + + public function isSessionAuthorized(Request $request, AccommodationBooking $booking): bool + { + $issuedAt = $booking->getAccessLinkIssuedAt(); + + if (null === $issuedAt) { + return false; + } + + if ($request->getSession()->get($this->sessionKey($booking)) !== $issuedAt->getTimestamp()) { + return false; + } + + $expiresAt = $this->expiresAt($booking); + + return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; + } + + private function sessionKey(AccommodationBooking $booking): string + { + return 'accommodation_offer_access_'.$booking->getUuid(); + } } diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 0dd47dba..9f77f645 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -291,6 +291,26 @@ class AccommodationBookingService return $booking; } + /** + * Persists the booking and sends the notification/confirmation emails in one go. + * + * @param AccommodationPrice[] $prices + * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services + */ + public function finalizeBooking( + AccommodationBookingDto $dto, + Accommodation $accommodation, + array $prices, + array $services, + ): AccommodationBooking { + $booking = $this->persist($dto, $accommodation, $prices, $services); + $this->issueAccessLinkForDirectBooking($booking); + $this->sendNotificationEmail($booking); + $this->sendCustomerConfirmationEmail($booking); + + return $booking; + } + public function refreshPriceSnapshot(AccommodationBooking $booking): void { $breakdown = $this->breakdownCalculator->computeCurrent($booking); diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index afa7b3b7..c80ff921 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -209,6 +209,11 @@ class AppRuntime implements RuntimeExtensionInterface $labels = []; foreach ($form->children as $child) { + // Buttons (e.g. submit buttons) aren't validatable fields and have no errors/valid vars. + if (!\array_key_exists('errors', $child->vars)) { + continue; + } + $hasErrors = \count($child->vars['errors']) > 0; $hasInvalidChildren = false === $child->vars['valid']; diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index c7b3d0d3..6bf75645 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -42,6 +42,11 @@
{{ form_row(form.isInquiry) }}
+ {% if booking.acceptedAt is not null %} +

+ Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }} +

+ {% endif %} {{ form_row(form.discount) }}
{{ form_row(form.remarks) }} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index e9993855..35003bda 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -55,6 +55,10 @@ {% endif %}
Status
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
+ {% if booking.acceptedAt is not null %} +
Angenommen am
+
{{ booking.acceptedAt | date('d.m.Y, H:i') }}
+ {% endif %}
diff --git a/templates/groups/booking/_offer_accept_confirmation_modal.html.twig b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig new file mode 100644 index 00000000..780d9874 --- /dev/null +++ b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig @@ -0,0 +1,31 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Angebot verbindlich buchen{% endblock %} + +{% block content %} + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }} + +

+ Möchtest du dieses Angebot jetzt verbindlich buchen? +

+ + {{ form_start(confirmationForm, { + 'action': path('app_groups_booking_offer_confirm', { uuid: booking.uuid }), + 'attr': { + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } + }) }} + {{ form_row(confirmationForm.termsAccepted) }} + +
+ + +
+ {{ form_end(confirmationForm) }} +{% endblock %} diff --git a/templates/groups/booking/_offer_summary.html.twig b/templates/groups/booking/_offer_summary.html.twig new file mode 100644 index 00000000..fbe43002 --- /dev/null +++ b/templates/groups/booking/_offer_summary.html.twig @@ -0,0 +1,168 @@ +{% block booking_summary %} +
+ {# Header #} +
+ {% if ctx.priceBreakdown is not null %} +
+ Gesamtpreis + {{ (displayTotal / 100)|format_currency(currency) }} +
+ {% endif %} +
+ Übersicht + +
+
+ + {# Content #} + +
+{% endblock %} diff --git a/templates/groups/booking/_pagination.html.twig b/templates/groups/booking/_pagination.html.twig index 5a8a4d62..4cde89f9 100644 --- a/templates/groups/booking/_pagination.html.twig +++ b/templates/groups/booking/_pagination.html.twig @@ -3,9 +3,10 @@ 1: 'app_groups_booking_step_1', 2: 'app_groups_booking_step_2', 3: 'app_groups_booking_step_3', + 4: 'app_groups_booking_step_4', } %}