'\d+'] )] public function editParticipant(int $index, Request $request): Response { $result = $this->loadBookingDtoOrRedirect($request); if ($result instanceof Response) { return $result; } $bookingDto = $result; $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); $form = $this->createParticipantForm($bookingDto, $index); $form->handleRequest($request); $isSubmitted = $form->isSubmitted(); $isDummyDataFill = $this ->prepopulationService ->isDummyDataFillRequested($bookingDto->participants[$index], $bookingDto->getMode()) ; if (true === $isSubmitted && true === $isDummyDataFill) { $this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index); $this->bookingService->preselectDefaultServices($bookingDto); $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $form = $this->createParticipantForm($bookingDto, $index); return $this->renderParticipantForm($form, $index, $bookingDto); } if (true === $isSubmitted) { $this->bookingService->preselectDefaultServices($bookingDto); $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); } $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); if (true === $isSubmitted && true === $form->isValid()) { $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $this->addNotificationsAsFlashMessages($notifications); return $this->redirectToRoute('app_booking_create_step_2'); } // Notifications were already cleared from the DTO above, so they have to be handed // to the render path too - otherwise every invalid submission silently swallows them return $this->renderParticipantForm($form, $index, $bookingDto, $notifications); } /** * HTMX refresh endpoint for individual participant form. */ #[Route( path: '/bookings/create/participants/{index}/refresh', name: 'app_booking_create_step_2_participant_refresh', requirements: ['index' => '\d+'], methods: ['POST'] )] public function refreshParticipantForm(int $index, Request $request): Response { $result = $this->loadBookingDtoOrRedirect($request); if ($result instanceof Response) { return $result; } $bookingDto = $result; $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); return $this->handleParticipantRefresh( $request, $bookingDto, $index, 'app_booking_create_step_2_participant_refresh' ); } /** * Flips the booking-level "I am not travelling myself" choice. * * Toggling on moves participant[0]'s personal data into the applicant record and leaves * behind a blank traveller that keeps only its index and room assignment: the services were * derived from an age that no longer applies. Toggling off moves it back. * * Only offered where the travel date allows it; elsewhere the request changes nothing. */ #[Route( path: '/bookings/create/applicant-toggle', name: 'app_booking_create_step_2_applicant_toggle', methods: ['POST'] )] public function toggleApplicantTravels(Request $request): Response { $result = $this->loadBookingDtoOrRedirect($request); if ($result instanceof Response) { return $result; } $bookingDto = $result; if (false === $bookingDto->travel->allowsNonTravellingApplicant()) { return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2')); } $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Captured before handleRequest(), which binds the new choice straight onto the DTO. $previousChoice = $bookingDto->applicantTravels; $form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto); $form->handleRequest($request); if (false === $form->isSubmitted() || false === $form->isValid()) { return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2')); } $applicantTravels = $bookingDto->applicantTravels; if ($applicantTravels !== $previousChoice) { $this->participantFormSupportService->ensureParticipantExists($bookingDto, 0); if (false === $applicantTravels) { $bookingDto->applicant = $bookingDto->participants[0]; $bookingDto->applicant->index = null; $bookingDto->participants[0] = $this->participantFormSupportService ->createBlankTravellerFrom($bookingDto->applicant); } else { $bookingDto->restoreTravellingApplicant(); } // Family insurance is not offered on a split booking, so a choice already made on // participant[0] has to go with it. if (false === $applicantTravels) { $this->participantFormSupportService->clearFamilyInsurance($bookingDto); } $this->bookingService->preselectDefaultServices($bookingDto); $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); } $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); // The choice rebuilds the whole participant list, so navigate rather than swap a fragment. return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2')); } /** * Contact data of an applicant who does not travel along. * * Guests only. A logged-in applicant's data comes from their account and is rendered as * static text on the step-2 card, because editing it here would create a duplicate BusPro * record - the same reason participant[0] is static when the applicant travels. */ #[Route( path: '/bookings/create/applicant', name: 'app_booking_create_applicant' )] public function editApplicant(Request $request): Response { $result = $this->loadBookingDtoOrRedirect($request); if ($result instanceof Response) { return $result; } $bookingDto = $result; if (true === $bookingDto->applicantTravels || false === $bookingDto->travel->allowsNonTravellingApplicant()) { return $this->redirectToRoute('app_booking_create_step_2'); } $bookingDto->applicant ??= new ParticipantDto(); $form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant); $form->handleRequest($request); // Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the // whole block with dummy data instead of saving what was typed. if ($form->isSubmitted() && $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) { $this->prepopulationService->fillDummyApplicant($bookingDto->applicant); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); return $this->redirectToRoute('app_booking_create_applicant'); } if ($form->isSubmitted() && $form->isValid()) { $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); return $this->redirectToRoute('app_booking_create_step_2'); } // Keep an invalid submission in the session so the entered data survives a reload. $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $bookingCreateContext = $this->createContextFactory->create( $bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION ); return $this->render('booking/create/applicant.html.twig', [ 'form' => $form, 'bookingCreateContext' => $bookingCreateContext, 'bookingDto' => $bookingCreateContext->bookingDto, 'summaryData' => $bookingCreateContext->summaryData, ]); } /** * @param FormInterface $form * @param array $notifications */ private function renderParticipantForm( FormInterface $form, int $index, BookingDto $bookingDto, array $notifications = [], ): Response { $bookingCreateContext = $this->createContextFactory->create( $bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION ); // The rendered template includes _partials/_flashes.html.twig, so flashes added here // are consumed by this very response instead of leaking into the next page $this->addNotificationsAsFlashMessages($notifications); return $this->render('booking/create/step_2_participant.html.twig', [ 'form' => $form, 'participantIndex' => $index, 'bookingCreateContext' => $bookingCreateContext, 'bookingDto' => $bookingCreateContext->bookingDto, 'summaryData' => $bookingCreateContext->summaryData, 'refreshRouteName' => 'app_booking_create_step_2_participant_refresh', ]); } private function loadBookingDtoOrRedirect(Request $request): BookingDto|Response { $bookingDto = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); if (null === $bookingDto) { $this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.'); return $this->redirectToRoute('app_login'); } return $bookingDto; } /** @return FormInterface */ private function createParticipantForm(BookingDto $bookingDto, int $index): FormInterface { $wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index); return $this->createForm( BookingParticipantType::class, $wrapper, $this->participantFormSupportService->getParticipantFormOptions($bookingDto) ); } private function handleParticipantRefresh( Request $request, BookingDto $bookingDto, int $index, string $refreshRouteName, ): Response { $form = $this->createForm( BookingParticipantType::class, $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index), $this->participantFormSupportService->getParticipantFormOptions($bookingDto, true) ); $form->handleRequest($request); $this->bookingService->preselectDefaultServices($bookingDto); $this->applicantInsuranceCascade->apply($bookingDto); if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { $this->bookingService->applyCreateBookingStatusRules($bookingDto); } $form = $this->createForm( BookingParticipantType::class, $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index), $this->participantFormSupportService->getParticipantFormOptions($bookingDto, true) ); $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); $this->bookingSessionService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); $bookingCreateContext = $this->createContextFactory->create( $bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION ); $response = $this->htmxOobResponse( 'booking/_participant_form.html.twig', ['participant_form', 'booking_summary'], [ 'form' => $form->createView(), 'participantIndex' => $index, 'bookingCreateContext' => $bookingCreateContext, 'bookingDto' => $bookingCreateContext->bookingDto, 'summaryData' => $bookingCreateContext->summaryData, 'refreshRouteName' => $refreshRouteName, ] ); if (false === empty($notifications)) { $response->headers->set('HX-Trigger', json_encode([ 'showNotifications' => $notifications, ])); } return $response; } /** * @param array $notifications */ private function addNotificationsAsFlashMessages(array $notifications): void { foreach ($notifications as $notification) { $this->addFlash($notification['type'], $notification['message']); } } }