wip: group inquiries and booking

This commit is contained in:
Björn Fromme committed 2026-07-16 09:03:42 +02:00
1 parent 1e59d38eb4
commit 12760844e2
34 files changed
+1810 -436

No files matched your search

+29
View File
@@ -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()
}
}
+2
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
@import "glightbox/dist/css/glightbox.min.css";
+13
View File
@@ -0,0 +1,13 @@
.rte-content {
p {
@apply pb-4;
}
ul {
@apply list-disc p-4;
}
ol {
@apply list-decimal p-4;
}
}
+7
View File
@@ -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",
+3 -2
View File
@@ -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"
}
}
@@ -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',
]
);
@@ -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',
};
+83 -9
View File
@@ -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);
}
}
+6 -74
View File
@@ -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);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step4Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-4', name: 'app_groups_booking_step_4', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
$dto = $this->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);
}
}
@@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints\IsTrue;
/**
* @extends AbstractType<AccommodationBookingDto>
*/
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');
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<null>
*/
class AccommodationInquiryConfirmationType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_token_id' => 'groups_booking_step4_inquiry',
]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
/**
* @extends AbstractType<null>
*/
class OfferAcceptConfirmationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLink = sprintf(
'<a href="%s" target="_blank" rel="noopener" class="underline">allgemeinen Geschäftsbedingungen (AGB)</a>',
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');
}
}
@@ -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();
}
}
@@ -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);
+5
View File
@@ -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'];
@@ -42,6 +42,11 @@
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{% if booking.acceptedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
</p>
{% endif %}
{{ form_row(form.discount) }}
<div class="lg:col-span-2">
{{ form_row(form.remarks) }}
@@ -55,6 +55,10 @@
{% endif %}
<dt class="font-medium">Status</dt>
<dd>{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}</dd>
{% if booking.acceptedAt is not null %}
<dt class="font-medium">Angenommen am</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl>
</div>
@@ -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) }}
<p class="mb-6">
Möchtest du dieses Angebot jetzt verbindlich buchen?
</p>
{{ 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) }}
<div class="mt-6 flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
<button type="submit" class="button button--primary">
Jetzt verbindlich buchen
</button>
</div>
{{ form_end(confirmationForm) }}
{% endblock %}
@@ -0,0 +1,168 @@
{% block booking_summary %}
<div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg"
id="booking-summary">
{# Header #}
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
{% if ctx.priceBreakdown is not null %}
<div class="flex items-center justify-between pb-2">
<span class="block uppercase font-semibold">Gesamtpreis</span>
<span class="font-semibold">{{ (displayTotal / 100)|format_currency(currency) }}</span>
</div>
{% endif %}
<div class="flex items-center justify-between">
<span class="block uppercase font-semibold">Übersicht</span>
<button type="button"
class="lg:hidden button button--small bg-primary-light"
data-action="toggle#toggle">
<svg class="w-4 h-4 text-white transition-transform"
data-toggle-target="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256">
<rect width="256" height="256" fill="none"/>
<polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
</svg>
</button>
</div>
</div>
{# Content #}
<div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto px-4 lg:px-8 py-8"
data-toggle-target="content">
{# Accommodation info #}
{% set hasImage = ctx.hotelCmsData is not null
and ctx.hotelCmsData.images is defined
and ctx.hotelCmsData.images.resized is defined
and ctx.hotelCmsData.images.resized.l is defined
and ctx.hotelCmsData.images.resized.l[0] is defined %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Unterkunft</div>
<div class="grid grid-cols-3 gap-x-4 p-2">
{% if hasImage %}
<img src="{{ ctx.hotelCmsData.images.resized.l[0].url }}"
alt="{{ ctx.hotelCmsData.images.resized.l[0].alt ?? ctx.accommodation.name }}"
class="block w-full h-auto">
{% endif %}
<div class="{{ hasImage ? 'col-span-2' : 'col-span-3' }}">
<span class="block font-semibold">{{ ctx.accommodation.name }}</span>
{% if ctx.hotelCmsData is not null and ctx.hotelCmsData.address is defined %}
<div class="text-sm text-gray-500 mt-1">{{ ctx.hotelCmsData.address|nl2br }}</div>
{% endif %}
</div>
</div>
</div>
{# Price breakdown #}
{% if ctx.priceBreakdown is not null %}
<div class="border border-primary-bg mt-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Preise</div>
<table class="w-full table-fixed text-sm">
<tr>
<td class="p-2">
Zeitraum
</td>
<td class="p-2 text-right">
{{ booking.dateFrom|date('d.m.Y') }} – {{ booking.dateTo|date('d.m.Y') }},
<br>
{{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }}
</td>
</tr>
<tr>
<td class="p-2">
Anzahl Personen
</td>
<td class="p-2 text-right">
{{ booking.paxCount }}
</td>
</tr>
{% if booking.minorsCount > 0 %}
<tr>
<td class="p-2">davon Kinder (0–3 J.)</td>
<td class="p-2 text-right">{{ booking.minorsCount }}</td>
</tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr>
<td class="p-2">davon Kinder (4–{{ ctx.accommodation.maxAdolescentAge }} J.)</td>
<td class="p-2 text-right">{{ booking.childrenCount }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.basePrice > 0 %}
<tr>
<td class="p-2">
Basispreis
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.includedPax }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.additionalPersonsPrice > 0 %}
<tr>
<td class="p-2">
Aufpreis Personenzahl
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.effectivePax - ctx.priceBreakdown.includedPax }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.shortTermSurcharge > 0 %}
<tr>
<td class="p-2">Aufpreis Kurzzeit</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.boardPrice > 0 %}
<tr>
<td class="p-2">Verpflegung{% if booking.boardServiceLabel %} ({{ booking.boardServiceLabel }}){% endif %}</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.undersubscriptionSurcharge > 0 %}
<tr>
<td class="p-2">
Verpflegungs-Aufschlag
<p class="text-xs text-gray-500">für Gruppen unter {{ ctx.priceBreakdown.undersubscriptionThreshold }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% for service in ctx.priceBreakdown.serviceDetails %}
{% if service.price > 0 %}
<tr>
<td class="p-2">{{ service.label }}</td>
<td class="p-2 text-right whitespace-nowrap">{{ (service.price / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% endfor %}
{% if ctx.priceBreakdown.runningCosts > 0 %}
<tr>
<td class="p-2">Strom- und Abfallgebühren</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-primary-bg font-semibold text-base">
<td class="p-2">
Gesamtpreis
<p class="text-xs font-bold">zzgl. ortsabhängiger Gebühren</p>
</td>
<td class="p-2 text-right whitespace-nowrap">
{{ (ctx.priceBreakdown.total / 100)|format_currency(currency) }}
</td>
</tr>
{% if booking.discount is not null %}
<tr class="text-green-700">
<td class="p-2">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap">– {{ (discountAmount / 100)|format_currency(currency) }}</td>
</tr>
<tr class="border-t border-primary-bg font-bold text-base text-green-700">
<td class="p-2">Gesamtpreis nach Rabatt</td>
<td class="p-2 text-right whitespace-nowrap">{{ (discountedTotal / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -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',
} %}
<nav class="pagination">
{% for step in 1..3 %}
{% for step in 1..4 %}
<div class="pagination-item">
{% if step < current_step %}
<a href="{{ path(step_routes[step]) }}"
@@ -7,9 +7,8 @@
{{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }}
{{ form_start(confirmationForm, {
'action': path('app_groups_booking_step_3_confirm'),
'action': path('app_groups_booking_step_4_confirm'),
'attr': {
'hx-post': path('app_groups_booking_step_3_confirm'),
'hx-target': '#htmx-modal',
'hx-swap': 'outerHTML',
}
@@ -17,8 +16,12 @@
{{ form_row(confirmationForm.termsAccepted) }}
<div class="mt-6 flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="submit" class="button button--primary">Buchung verbindlich absenden</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>Abbrechen</button>
<button type="submit" class="button button--primary">
Buchung verbindlich absenden
</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
</div>
{{ form_end(confirmationForm) }}
{% endblock %}
@@ -0,0 +1,18 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Anfrage bestätigen{% endblock %}
{% block content %}
<p class="mb-6">Möchtest du diese unverbindliche Anfrage jetzt absenden?</p>
{{ form_start(inquiryForm, { 'action': path('app_groups_booking_step_4') }) }}
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="submit" class="button button--primary">
Jetzt anfragen
</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
</div>
{{ form_end(inquiryForm) }}
{% endblock %}
@@ -0,0 +1,31 @@
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Persönliche Daten</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 p-2 text-sm">
<div>
<span class="text-gray-600 font-semibold">Gruppenname:</span>
{{ dto.groupName }}
</div>
<div>
<span class="text-gray-600 font-semibold">Name:</span>
{{ dto.salutation }} {{ dto.firstName }} {{ dto.lastName }}
</div>
<div>
<span class="text-gray-600 font-semibold">E-Mail:</span>
{{ dto.email }}
</div>
<div>
<span class="text-gray-600 font-semibold">Telefon:</span>
{{ dto.phone }}
</div>
<div class="md:col-span-2">
<span class="text-gray-600 font-semibold">Adresse:</span>
{{ dto.street }}, {{ dto.zip }} {{ dto.city }}
</div>
{% if dto.remarks %}
<div class="md:col-span-2">
<span class="text-gray-600 font-semibold">Anmerkungen:</span>
{{ dto.remarks }}
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,39 @@
{% set selectedBoardService = null %}
{% for service in ctx.boardServices %}
{% if service.id == dto.selectedBoardServiceId %}
{% set selectedBoardService = service %}
{% endif %}
{% endfor %}
{% set selectedAdditionalServices = ctx.additionalServices|filter(service => service.id in dto.selectedAdditionalServiceIds) %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Ausgewählte Leistungen</div>
<div class="p-2 text-sm">
<div class="mb-2">
<span class="text-gray-600 font-semibold">Verpflegung:</span>
{% if selectedBoardService %}
{{ selectedBoardService.label }}
{% if selectedBoardService.description %}
<div class="text-xs text-gray-500">{{ selectedBoardService.description|nl2br }}</div>
{% endif %}
{% else %}
Selbstversorgung
{% endif %}
</div>
{% if selectedAdditionalServices is not empty %}
<div>
<span class="text-gray-600 font-semibold">Zusatzleistungen:</span>
<ul class="list-disc list-inside">
{% for service in selectedAdditionalServices %}
<li>
{{ service.label }}
— {{ (service.price / 100)|format_currency(ctx.accommodation.currency) }} {{ service.type.label|trans }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
+121 -68
View File
@@ -1,85 +1,138 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
<div class="max-w-2xl w-full mx-auto p-8">
<h1 class="text-2xl font-bold mb-2">
{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}
</h1>
<p class="text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
{% if booking.discount is not null and priceBreakdown is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %}
{% endif %}
{% set displayTotal = discountedTotal ?? (priceBreakdown.total ?? null) %}
<div class="grid grid-cols-2 gap-y-2 text-sm mb-8">
<dt class="font-medium">Anreise</dt>
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
<dt class="font-medium">Abreise</dt>
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
<dt class="font-medium">Nächte</dt>
<dd>{{ booking.nights }}</dd>
<dt class="font-medium">Personen</dt>
<dd>{{ booking.paxCount }}</dd>
{% if booking.boardServiceLabel %}
<dt class="font-medium">Verpflegung</dt>
<dd>{{ booking.boardServiceLabel }}</dd>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
<div id="booking-summary-wrapper"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/booking/_offer_summary.html.twig' with {
booking: booking,
ctx: ctx,
currency: currency,
displayTotal: displayTotal,
discountAmount: discountAmount ?? null,
discountedTotal: discountedTotal ?? null,
} %}
</div>
{# Main area #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0 overflow-y-auto px-4 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-2">
{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}
</h1>
<p class="text-gray-500 mb-6">{{ ctx.accommodation.name }}</p>
{# Booked services #}
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Gebuchte Leistungen</h2>
<ul class="text-sm list-disc list-inside">
{% if booking.boardServiceLabel %}
<li>{{ booking.boardServiceLabel }}</li>
{% endif %}
{% for service in booking.additionalServices %}
<li>{{ service.label }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if ctx.hotelCmsData is not null %}
<div class="rte-content">
<h2>
Die Unterkunft
</h2>
{{ ctx.hotelCmsData.description | raw }}
<h2>
Details zur Unterkunft
</h2>
{{ ctx.hotelCmsData.features | raw }}
<h2>
Die Zimmer
</h2>
{{ ctx.hotelCmsData.roomTypes | raw }}
<h2>
Weitere Infos
</h2>
{{ ctx.hotelCmsData.additionalInformation | raw }}
<h2>
Bilder
</h2>
</div>
{% set largeImages = ctx.hotelCmsData.images.resized.l %}
{% set mediumImages = ctx.hotelCmsData.images.resized.m %}
{% if largeImages | length > 0 %}
<div class="grid md:grid-cols-2 gap-8" {{ stimulus_controller('gallery') }}>
{% for image in largeImages %}
<div class="relative block w-full h-auto cursor-pointer{{ loop.index0 >= 2 ? ' hidden' : '' }}"
data-gallery-target="item"
data-gallery-href="{{ image.url }}"
data-gallery-alt="{{ image.alt ?? ctx.accommodation.name }}">
<img src="{{ mediumImages[loop.index0].url ?? image.url }}"
alt="{{ image.alt ?? ctx.accommodation.name }}"
class="block w-full h-auto">
{% if loop.index0 == 1 and largeImages | length > 2 %}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white font-bold text-lg">
+{{ largeImages | length - 2 }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{# TODO: additional hotel and location information fetched from the CMS #}
{% if booking.remarks %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Anmerkungen</h2>
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
</div>
{% endif %}
{% if booking.inquiry %}
<div>
<button type="button"
class="button button--primary"
hx-get="{{ path('app_groups_booking_offer_confirm', { uuid: booking.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
Angebot verbindlich buchen
</button>
</div>
{% else %}
{% if booking.acceptedAt is not null %}
<p class="text-sm text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y') }} um {{ booking.acceptedAt | date('H:i') }} Uhr
</p>
{% endif %}
{% endif %}
</div>
{% if booking.additionalServices | length > 0 %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Zusatzleistungen</h2>
<ul class="text-sm list-disc list-inside">
{% for service in booking.additionalServices %}
<li>{{ service.label }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if priceBreakdown is not null %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Preise</h2>
<table class="w-full text-sm">
<tr class="border-t border-gray-200 font-semibold">
<td class="pt-2">
Gesamtpreis
<span class="block text-xs font-normal text-gray-500">zzgl. ortsabhängiger Gebühren</span>
</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
</tr>
{% if booking.discount is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %}
<tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
</tr>
<tr class="border-t border-gray-200 font-bold text-green-700">
<td class="pt-2">Gesamtpreis nach Rabatt</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (discountedTotal / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
{% if booking.inquiry %}
<form method="post" action="{{ app.request.requestUri }}">
<input type="hidden"
name="_token"
value="{{ csrf_token('accept_accommodation_offer_' ~ booking.uuid) }}">
<button type="submit" class="button button--primary">
Angebot verbindlich buchen
</button>
</form>
{% else %}
{% if justAccepted %}
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-w-lg w-full z-30">
{% include '_partials/_alert.html.twig' with {
level: 'success',
title: 'Bestätigt',
messages: ['Deine Buchung ist verbindlich bestätigt.'],
} %}
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
+3 -6
View File
@@ -96,16 +96,13 @@
Zurück
</a>
<div class="space-x-2">
<button type="submit" name="submitInquiry" class="button button--secondary">anfragen</button>
<button type="submit" name="submitBooking" class="{{ html_classes('button button--primary', { 'opacity-50': dto.isInquiry }) }}"{% if dto.isInquiry %} disabled{% endif %}>buchen</button>
<button type="submit" class="button button--primary">
weiter zu Schritt 4
</button>
</div>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% if confirmationForm is defined %}
{% include 'groups/booking/_step3_confirmation_modal.html.twig' with { confirmationForm: confirmationForm } %}
{% endif %}
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends 'layout_booking.html.twig' %}
{% block title %}Buchung bestätigen{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 4 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Static sidebar recap #}
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %}
</div>
{# Confirmation recap #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include '_partials/_flashes.html.twig' %}
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 4 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Bitte bestätige deine Angaben
</h2>
{% if dto.isInquiry %}
{% include '_partials/_alert.html.twig' with {
level: 'warning',
title: 'Diese Buchung wird als unverbindliche Anfrage behandelt',
messages: dto.inquiryReasons
} %}
{% endif %}
{% include 'groups/booking/_step4_services_recap.html.twig' with { 'dto': dto, 'ctx': ctx } %}
{% include 'groups/booking/_step4_personal_data_recap.html.twig' with { 'dto': dto } %}
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex items-center justify-between">
<a href="{{ path('app_groups_booking_step_3') }}" class="button button--secondary">
Zurück
</a>
<div class="space-x-2">
<button type="button"
class="button button--secondary"
hx-get="{{ path('app_groups_booking_step_4_confirm_inquiry') }}"
hx-target="body"
hx-swap="beforeend">
anfragen
</button>
<button type="button"
class="{{ html_classes('button button--primary', { 'opacity-50': dto.isInquiry }) }}"
{% if dto.isInquiry %}disabled{% endif %}
hx-get="{{ path('app_groups_booking_step_4_confirm') }}"
hx-target="body"
hx-swap="beforeend">
Jetzt verbindlich buchen
</button>
</div>
</div>
</div>
{% endblock %}
+3 -3
View File
@@ -1,8 +1,8 @@
<div id="htmx-modal"
class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
<div class="absolute inset-0 w-full h-full backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute inset-0 w-full h-full bg-gray-300/50 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
<div class="relative bg-white py-8 rounded-md">
<div class="relative bg-white py-8 rounded-md shadow-lg">
<div class="flex justify-between pb-4 px-8">
<div class="text-2xl font-bold">
{% block title %}{% endblock %}
@@ -13,7 +13,7 @@
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
</div>
<div class="max-h-96 overflow-y-scroll">
<div class="max-h-svh overflow-y-auto">
<div class="px-8">
{% block content %}{% endblock %}
</div>
@@ -6,17 +6,13 @@ namespace App\Tests\Controller\Accommodation;
use App\Controller\Groups\Step3Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Form\AccommodationStep3ConfirmationType;
use App\Form\AccommodationStep3Type;
use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -42,13 +38,7 @@ class Step3ControllerTest extends TestCase
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$form = $this->createMock(FormInterface::class);
$form
->method('handleRequest')
->willReturnSelf();
$form
->method('isSubmitted')
->willReturn(false);
$form = $this->createFormMock(submitted: false, valid: false);
$accommodation = (new Accommodation())
->setName('Hotel')
@@ -84,7 +74,7 @@ class Step3ControllerTest extends TestCase
self::assertSame([$additionalService], $controller->renderedParameters['ctx']->additionalServices);
}
public function testClickingBuchenOpensConfirmationModalWithoutPersisting(): void
public function testValidSubmitRedirectsToStep4WithoutPersisting(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
@@ -98,20 +88,7 @@ class Step3ControllerTest extends TestCase
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$submitBookingButton = $this->getMockBuilder(SubmitButton::class)
->disableOriginalConstructor()
->onlyMethods(['isClicked'])
->getMock();
$submitBookingButton->method('isClicked')->willReturn(true);
$mainForm = $this->createMock(FormInterface::class);
$mainForm->method('handleRequest')->willReturnSelf();
$mainForm->method('isSubmitted')->willReturn(true);
$mainForm->method('isValid')->willReturn(true);
$mainForm->method('has')->with('submitBooking')->willReturn(true);
$mainForm->method('get')->with('submitBooking')->willReturn($submitBookingButton);
$confirmationForm = $this->createMock(FormInterface::class);
$form = $this->createFormMock(submitted: true, valid: true);
$accommodation = (new Accommodation())
->setName('Hotel')
@@ -126,115 +103,65 @@ class Step3ControllerTest extends TestCase
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->expects(self::never())->method('persist');
$bookingService->expects(self::never())->method('finalizeBooking');
$controller = new TestableAccommodationStep3Controller(
$bookingService,
$sessionManager,
[AccommodationStep3Type::class => $mainForm, AccommodationStep3ConfirmationType::class => $confirmationForm],
);
$controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_step_4', $response->getTargetUrl());
self::assertSame(4, $dto->currentStep);
self::assertSame($dto, $sessionManager->getDto($request));
}
public function testInvalidSubmitReRendersFormWithErrors(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-3', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$form = $this->createFormMock(submitted: true, valid: false);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form);
$response = $controller->index($request);
self::assertSame(200, $response->getStatusCode());
self::assertSame('groups/booking/step_3.html.twig', $controller->renderedView);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertFalse($dto->forceInquiry);
self::assertSame(3, $dto->currentStep);
self::assertSame($dto, $sessionManager->getDto($request));
}
public function testConfirmPersistsBookingOnValidConfirmationForm(): void
private function createFormMock(bool $submitted, bool $valid): FormInterface
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
$dto->accommodationId = 1;
$dto->isInquiry = false;
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn($submitted);
$form->method('isValid')->willReturn($valid);
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-3/confirm', 'POST');
$request->setSession($session);
$request->headers->set('HX-Request', 'true');
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setIsInquiry(false);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->expects(self::once())->method('persist')->willReturn($booking);
$bookingService->expects(self::once())->method('sendNotificationEmail');
$controller = new TestableAccommodationStep3Controller(
$bookingService,
$sessionManager,
[AccommodationStep3ConfirmationType::class => $confirmationForm],
);
$response = $controller->confirm($request);
self::assertSame(200, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
self::assertNull($sessionManager->getDto($request));
}
public function testConfirmRerendersModalWhenTermsNotAccepted(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
$dto->accommodationId = 1;
$dto->isInquiry = false;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-3/confirm', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(false);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->expects(self::never())->method('persist');
$controller = new TestableAccommodationStep3Controller(
$bookingService,
$sessionManager,
[AccommodationStep3ConfirmationType::class => $confirmationForm],
);
$response = $controller->confirm($request);
self::assertSame(200, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/booking/_step3_confirmation_modal.html.twig', $controller->renderedView);
self::assertNotNull($sessionManager->getDto($request));
return $form;
}
}
@@ -247,13 +174,10 @@ final class TestableAccommodationStep3Controller extends Step3Controller
public string $renderedView = '';
/**
* @param FormInterface<mixed>|array<class-string, FormInterface<mixed>> $form
*/
public function __construct(
AccommodationBookingService $bookingService,
AccommodationSessionManager $sessionManager,
private readonly FormInterface|array $form,
private readonly FormInterface $form,
) {
parent::__construct($bookingService, $sessionManager);
}
@@ -263,10 +187,6 @@ final class TestableAccommodationStep3Controller extends Step3Controller
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
if (is_array($this->form)) {
return $this->form[$type];
}
return $this->form;
}
+353 -131
View File
@@ -5,145 +5,32 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups;
use App\Controller\Groups\OfferController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class OfferControllerTest extends TestCase
{
public function testGetRendersOfferWithAcceptFormForInquiry(): void
public function testAccessAuthorizesSessionAndRedirectsToView(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('<html>offer</html>', $response->getContent());
self::assertSame('groups/booking/offer.html.twig', $controller->renderedView);
self::assertSame(['booking' => $booking, 'priceBreakdown' => ['total' => 1000]], $controller->renderedParameters);
}
public function testPostAcceptsInquiryAndRedirectsToSameUrl(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
$request = Request::create(
'/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc',
'POST',
['_token' => 'valid-token'],
);
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
self::assertSame($request->getUri(), $response->headers->get('Location'));
self::assertSame('accept_accommodation_offer_'.$booking->getUuid(), $controller->csrfTokenId);
self::assertSame('valid-token', $controller->csrfToken);
}
public function testPostRejectsInvalidCsrfToken(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
$controller->csrfValid = false;
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
$controller->index(
$booking->getUuid(),
Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST', ['_token' => 'invalid']),
);
}
public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
// A POST on an already-accepted booking should not trigger acceptBooking again — falls through to render.
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer.html.twig', $controller->renderedView);
}
public function testInvalidLinkReturnsUnavailableResponse(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(false);
$linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking);
$controller = new TestableOfferController(
$bookingRepository,
@@ -152,13 +39,37 @@ class OfferControllerTest extends TestCase
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
}
public function testAccessRendersUnavailableForInvalidLink(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isValidLinkRequest')->willReturn(false);
$linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testUnknownUuidReturnsUnavailableResponse(): void
public function testAccessRendersUnavailableForUnknownUuid(): void
{
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
@@ -173,29 +84,340 @@ class OfferControllerTest extends TestCase
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
$response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testViewRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$bookingService->method('loadHotelCmsData')->willReturn(null);
$controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('<html>offer</html>', $response->getContent());
self::assertSame('groups/booking/offer.html.twig', $controller->renderedView);
self::assertFalse($controller->renderedParameters['justAccepted']);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation);
}
public function testViewRendersUnavailableWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testViewMarksJustAcceptedWhenFlashIsPresent(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(['total' => 1000]);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$this->createMock(AccommodationBookingService::class),
);
$session = new Session(new MockArraySessionStorage());
$session->getFlashBag()->add('accommodation_offer_accepted', true);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession($session);
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($controller->renderedParameters['justAccepted']);
}
public function testConfirmGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
}
public function testConfirmPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
}
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$request->headers->set('HX-Request', 'true');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView);
}
public function testConfirmRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsForUnknownUuid(): void
{
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isSessionAuthorized');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$this->createMock(AccommodationBookingService::class),
);
$response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
}
final class TestableOfferController extends OfferController
{
public bool $csrfValid = true;
public ?string $csrfTokenId = null;
public ?string $csrfToken = null;
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
$this->csrfTokenId = $id;
$this->csrfToken = $token;
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingBreakdownCalculator $breakdownCalculator,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $confirmationForm = null,
) {
parent::__construct($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
}
return $this->csrfValid;
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.');
}
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
{
return 'https://example.test/agb/';
}
protected function addFlash(string $type, mixed $message): void
{
// no-op: avoids requiring a full service container in these unit tests
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups;
use App\Controller\Groups\Step4Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class Step4ControllerTest extends TestCase
{
public function testIndexRendersRecapWhenStepFourReached(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$dto->isInquiry = false;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'GET');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $this->createMock(FormInterface::class));
$response = $controller->index($request);
self::assertSame(200, $response->getStatusCode());
self::assertSame('groups/booking/step_4.html.twig', $controller->renderedView);
self::assertSame($dto, $controller->renderedParameters['dto']);
}
public function testIndexRedirectsToStep3WhenNotYetReached(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'GET');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$controller = new TestableStep4Controller(
$this->createMock(AccommodationBookingService::class),
$sessionManager,
$this->createMock(FormInterface::class),
);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl());
}
public function testIndexPostWithValidCsrfPersistsAsInquiryAndRedirects(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setIsInquiry(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->expects(self::once())->method('finalizeBooking')->willReturn($booking);
$inquiryForm = $this->createMock(FormInterface::class);
$inquiryForm->method('handleRequest')->willReturnSelf();
$inquiryForm->method('isSubmitted')->willReturn(true);
$inquiryForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_success', $response->getTargetUrl());
self::assertTrue($dto->forceInquiry);
self::assertNull($sessionManager->getDto($request));
}
public function testIndexPostWithInvalidFormThrowsAccessDenied(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->expects(self::never())->method('finalizeBooking');
$inquiryForm = $this->createMock(FormInterface::class);
$inquiryForm->method('handleRequest')->willReturnSelf();
$inquiryForm->method('isSubmitted')->willReturn(true);
$inquiryForm->method('isValid')->willReturn(false);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm);
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
$controller->index($request);
}
public function testConfirmInquiryGetRendersModal(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$controller = new TestableStep4Controller(
$this->createMock(AccommodationBookingService::class),
$sessionManager,
$this->createMock(FormInterface::class),
);
$response = $controller->confirmInquiry($request);
self::assertSame(200, $response->getStatusCode());
self::assertSame('groups/booking/_step4_inquiry_confirmation_modal.html.twig', $controller->renderedView);
}
public function testConfirmInquiryRedirectsToStep3WhenNotYetReached(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 3;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$controller = new TestableStep4Controller(
$this->createMock(AccommodationBookingService::class),
$sessionManager,
$this->createMock(FormInterface::class),
);
$response = $controller->confirmInquiry($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl());
}
public function testConfirmGetRendersModalWithFreshForm(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm', 'GET');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->expects(self::never())->method('finalizeBooking');
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm);
$response = $controller->confirm($request);
self::assertSame(200, $response->getStatusCode());
self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertSame($dto, $sessionManager->getDto($request));
}
public function testConfirmPostValidPersistsAndRedirects(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm', 'POST');
$request->setSession($session);
$request->headers->set('HX-Request', 'true');
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setIsInquiry(false);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->expects(self::once())->method('finalizeBooking')->willReturn($booking);
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm);
$response = $controller->confirm($request);
self::assertSame(200, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
self::assertNull($sessionManager->getDto($request));
}
public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->expects(self::never())->method('finalizeBooking');
$confirmationForm = $this->createMock(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(false);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm);
$response = $controller->confirm($request);
self::assertSame(200, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView);
self::assertNotNull($sessionManager->getDto($request));
}
}
final class TestableStep4Controller extends Step4Controller
{
/**
* @var array<string, mixed>
*/
public array $renderedParameters = [];
public string $renderedView = '';
public function __construct(
AccommodationBookingService $bookingService,
AccommodationSessionManager $sessionManager,
private readonly FormInterface $form,
) {
parent::__construct($bookingService, $sessionManager);
}
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
{
return 'https://example.test/agb/';
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route;
}
protected function addFlash(string $type, mixed $message): void
{
// no-op: avoids requiring a full service container in these unit tests
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return new Response('ok');
}
}
@@ -8,6 +8,8 @@ use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingLinkSigner;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AccommodationBookingLinkSignerTest extends TestCase
@@ -94,6 +96,77 @@ class AccommodationBookingLinkSignerTest extends TestCase
self::assertSame('2026-04-01T00:00:00+00:00', $signer->expiresAt($booking)?->format(\DATE_ATOM));
}
public function testSessionIsAuthorizedAfterAuthorizeSession(): void
{
$booking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$request = $this->requestWithSession();
self::assertFalse($signer->isSessionAuthorized($request, $booking));
$signer->authorizeSession($request, $booking);
self::assertTrue($signer->isSessionAuthorized($request, $booking));
}
public function testSessionAuthorizationIsPerBooking(): void
{
$booking = $this->bookingWithAccessLink();
$otherBooking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$request = $this->requestWithSession();
$signer->authorizeSession($request, $booking);
self::assertTrue($signer->isSessionAuthorized($request, $booking));
self::assertFalse($signer->isSessionAuthorized($request, $otherBooking));
}
public function testSessionAuthorizationIsRevokedWhenLinkIsRegenerated(): void
{
$booking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$request = $this->requestWithSession();
$signer->authorizeSession($request, $booking);
self::assertTrue($signer->isSessionAuthorized($request, $booking));
// Regenerating the access link overwrites accessLinkIssuedAt — the
// previously-authorized session no longer matches.
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute'));
self::assertFalse($signer->isSessionAuthorized($request, $booking));
}
public function testSessionAuthorizationFailsPastTtlEvenIfSessionMatches(): void
{
$booking = new AccommodationBooking();
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days'));
$signer = $this->createSigner();
$request = $this->requestWithSession();
$signer->authorizeSession($request, $booking);
self::assertFalse($signer->isSessionAuthorized($request, $booking));
}
public function testSessionAuthorizationFailsWithoutAccessLinkIssuedAt(): void
{
$booking = new AccommodationBooking();
$signer = $this->createSigner();
$request = $this->requestWithSession();
self::assertFalse($signer->isSessionAuthorized($request, $booking));
}
private function requestWithSession(): Request
{
$request = Request::create('https://example.com/');
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
private function bookingWithAccessLink(): AccommodationBooking
{
$booking = new AccommodationBooking();