bookingRepository->findOneBy(['uuid' => $uuid]); if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) { return $this->render('groups/booking/offer_unavailable.html.twig'); } if (!$this->isCustomerVisible($booking)) { return $this->render('groups/booking/offer_unavailable.html.twig'); } $this->linkSigner->authorizeSession($request, $booking); return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid])); } #[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'); } if (!$this->isCustomerVisible($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, ); return $this->render('groups/booking/offer.html.twig', [ 'booking' => $booking, 'priceBreakdown' => $priceBreakdown, 'ctx' => $ctx, ]); } #[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); } // Offen is the one status that means an offer is out and awaiting acceptance, so it // is the whole guard — mirroring acceptBooking(), which no-ops on anything else. if (!$booking->isOpen()) { return $this->redirectToOfferPage($request, $uuid); } $dto = new OfferAcceptDto(remarks: $booking->getRemarks()); $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [ 'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()), ]); $confirmationForm->handleRequest($request); if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { // The textarea is prefilled, so the submitted value is the complete remark — // an emptied field arrives as null and must clear the stored one, not keep it. $this->bookingService->acceptBooking($booking, $dto->remarks ?? ''); $this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.'); return $this->redirectToOfferPage($request, $uuid); } return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [ 'booking' => $booking, 'confirmationForm' => $confirmationForm, ]); } /** * A draft and an inquiry the office has not worked through yet are not ready to be * shown, and a discarded booking must not be viewable any more — in all three cases * the link behaves as if it had expired. */ private function isCustomerVisible(AccommodationBooking $booking): bool { return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded(); } /** * 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); } }