146 lines
6.1 KiB
PHP
146 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Groups;
|
|
|
|
use App\Entity\Groups\AccommodationBooking;
|
|
use App\Form\Model\OfferAcceptDto;
|
|
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;
|
|
use App\Service\AccommodationBookingService;
|
|
use App\Service\AccommodationTermsUrlProvider;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
class OfferController extends AbstractController
|
|
{
|
|
use HxTrait;
|
|
|
|
public function __construct(
|
|
private readonly AccommodationBookingRepository $bookingRepository,
|
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
|
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
|
private readonly AccommodationBookingService $bookingService,
|
|
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
|
|
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);
|
|
}
|
|
}
|