diff --git a/assets/controllers/loading_controller.js b/assets/controllers/loading_controller.js index b9f7bdb..8b0cab1 100644 --- a/assets/controllers/loading_controller.js +++ b/assets/controllers/loading_controller.js @@ -1,12 +1,81 @@ -import { Controller} from '@hotwired/stimulus' +import {Controller} from '@hotwired/stimulus' export default class extends Controller { - static targets = [ 'indicator' ] - static classes = [ 'hidden' ] + static targets = ['indicator'] + static classes = ['hidden'] + + connect() { + this.isVisible = false + this.debounceTimeout = null + + // Bind event handlers to preserve context + this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this) + this.boundHandleAfterRequest = this.handleAfterRequest.bind(this) + + // Listen to HTMX events + document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) + document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest) + } + + disconnect() { + // Clean up event listeners + document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest) + document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest) + + // Clear any pending timeout + if (this.debounceTimeout) { + clearTimeout(this.debounceTimeout) + } + } + + handleBeforeRequest(event) { + // Don't start a new debounce if already visible + if (this.isVisible) { + return + } + + // Show indicator after 200ms delay for field refreshes + this.debounceTimeout = setTimeout(() => { + this.show() + }, 200) + } + + handleAfterRequest(event) { + // Clear debounce timer if request completes before 200ms + if (this.debounceTimeout) { + clearTimeout(this.debounceTimeout) + this.debounceTimeout = null + } + + // Check if response contains HX-Redirect header + const xhr = event.detail.xhr + const hxRedirect = xhr.getResponseHeader('HX-Redirect') + + // Keep loading indicator visible if redirecting + if (hxRedirect) { + // Ensure indicator is visible during redirect + if (!this.isVisible) { + this.show() + } + return + } + + // Hide indicator if it's visible + this.hide() + } + + show() { + this.isVisible = true + this.indicatorTarget.classList.remove(this.hiddenClass) + } + + hide() { + this.isVisible = false + this.indicatorTarget.classList.add(this.hiddenClass) + } toggle() { this.indicatorTarget.classList.remove(this.hiddenClass) - this.element.scrollIntoView({ block: 'start', behavior: 'smooth'}) } } diff --git a/src/Controller/Booking/CreateStep1Controller.php b/src/Controller/Booking/CreateStep1Controller.php index b171f4f..cd3c302 100644 --- a/src/Controller/Booking/CreateStep1Controller.php +++ b/src/Controller/Booking/CreateStep1Controller.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Booking; use App\Form\BookingCreateStep1Type; +use App\Htmx\HxTrait; use App\Service\BookingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -21,6 +22,7 @@ class CreateStep1Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; + use HxTrait; public function __construct( private readonly BookingService $bookingService, @@ -50,6 +52,12 @@ class CreateStep1Controller extends AbstractController } $form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [ + 'attr' => [ + 'hx-post' => $this->generateUrl('app_booking_create_step_1'), + 'hx-target' => '#form-wrapper', + 'hx-select' => '#form-wrapper', + 'hx-swap' => 'outerHTML', + ], 'validation_groups' => ['booking_create_step_1'], ]); @@ -66,7 +74,7 @@ class CreateStep1Controller extends AbstractController // Clear baseline snapshot when moving to step 2 $this->bookingService->clearBaselineSnapshot($request); - return $this->redirectToRoute('app_booking_create_step_2'); + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2')); } $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); @@ -85,33 +93,44 @@ class CreateStep1Controller extends AbstractController } /** - * HTMX endpoint for live summary updates in step 1. + * Handles HTMX requests for dynamic form updates when room selections change by submitting the form + * without validation and returning freshly rendered blocks. */ - #[Route('/bookings/create/room-summary', name: 'app_booking_create_step_1_room_summary', methods: ['POST'])] - public function roomSummary(Request $request): Response + #[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])] + public function refreshRoomSelection(Request $request): Response { - $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); + $result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request); if ($result instanceof Response) { return $result; } $bookingCreateDto = $result; - // Process the form to update the DTO with the latest room selection + // Process form data without validation to capture current state $form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [ 'validation_groups' => false, ]); $form->handleRequest($request); + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); + $groupedRooms = $this->bookingService->groupRoomsBySelectionType($availableRooms); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms); - return $this->render('booking/_summary.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'roomSummary' => $summary['selectedRooms'], - 'participantCount' => $summary['participantCount'], - 'pricingData' => $summary['pricing'], - 'groupedSelectedRooms' => $groupedSelectedRooms, - ]); + // The DTO is now updated with the latest selection. + // We can now render the blocks with the fresh data. + return $this->htmxOobResponse( + 'booking/create_step_1.html.twig', + ['room_selection_form', 'booking_summary'], + [ + 'form' => $form->createView(), + 'bookingCreateDto' => $bookingCreateDto, + 'participantCount' => $summary['participantCount'], + 'pricingData' => $summary['pricing'], + 'groupedRooms' => $groupedRooms, + 'groupedSelectedRooms' => $groupedSelectedRooms, + ] + ); } } diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php index 3dd27c4..7df9094 100644 --- a/src/Controller/Booking/CreateStep2Controller.php +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace App\Controller\Booking; -use App\Controller\Traits\HtmxControllerTrait; use App\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use App\Service\RoomAssignmentService; @@ -27,7 +27,7 @@ class CreateStep2Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; - use HtmxControllerTrait; + use HxTrait; public function __construct( private readonly BookingService $bookingService, @@ -70,7 +70,13 @@ class CreateStep2Controller extends AbstractController $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'attr' => ['novalidate' => 'novalidate'], + 'attr' => [ + 'novalidate' => 'novalidate', + 'hx-post' => $this->generateUrl('app_booking_create_step_2'), + 'hx-target' => '#form-wrapper', + 'hx-select' => '#form-wrapper', + 'hx-swap' => 'outerHTML', + ], 'validation_groups' => ['booking_create_step_2'], ]); @@ -80,7 +86,7 @@ class CreateStep2Controller extends AbstractController $bookingCreateDto->currentStep = 3; $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - return $this->redirectToRoute('app_booking_create_step_3'); + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); } $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); diff --git a/src/Controller/Booking/CreateStep3Controller.php b/src/Controller/Booking/CreateStep3Controller.php index cf67296..3db0dcd 100644 --- a/src/Controller/Booking/CreateStep3Controller.php +++ b/src/Controller/Booking/CreateStep3Controller.php @@ -6,8 +6,8 @@ namespace App\Controller\Booking; use App\BusProNet\ApiClient; use App\BusProNet\Model\Notification; -use App\Controller\Traits\HtmxControllerTrait; use App\Form\BookingCreateStep3Type; +use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use Psr\Log\LoggerInterface; @@ -23,7 +23,7 @@ class CreateStep3Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; - use HtmxControllerTrait; + use HxTrait; public function __construct( private readonly BookingService $bookingService, @@ -50,7 +50,14 @@ class CreateStep3Controller extends AbstractController return $redirect; } - $form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto); + $form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [ + 'attr' => [ + 'hx-post' => $this->generateUrl('app_booking_create_step_3'), + 'hx-target' => '#form-wrapper', + 'hx-select' => '#form-wrapper', + 'hx-swap' => 'outerHTML', + ], + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -107,7 +114,7 @@ class CreateStep3Controller extends AbstractController $bookingCreateDto->currentStep = 4; $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); - return $this->redirectToRoute('app_booking_create_step_4'); + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4')); } catch (\Exception $e) { $this->logger->error('Booking inquiry exception', [ 'exception' => $e->getMessage(), diff --git a/src/Controller/Booking/CreateStep4Controller.php b/src/Controller/Booking/CreateStep4Controller.php index 170d66d..2e2b726 100644 --- a/src/Controller/Booking/CreateStep4Controller.php +++ b/src/Controller/Booking/CreateStep4Controller.php @@ -6,8 +6,8 @@ namespace App\Controller\Booking; use App\BusProNet\ApiClient; use App\BusProNet\Model\Notification; -use App\Controller\Traits\HtmxControllerTrait; use App\Form\BookingCreateStep4Type; +use App\Htmx\HxTrait; use App\Service\BookingService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -22,7 +22,7 @@ class CreateStep4Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; - use HtmxControllerTrait; + use HxTrait; public function __construct( private readonly BookingService $bookingService, @@ -48,7 +48,14 @@ class CreateStep4Controller extends AbstractController return $redirect; } - $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto); + $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ + 'attr' => [ + 'hx-post' => $this->generateUrl('app_booking_create_step_4'), + 'hx-target' => '#form-wrapper', + 'hx-select' => '#form-wrapper', + 'hx-swap' => 'outerHTML', + ], + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -80,7 +87,7 @@ class CreateStep4Controller extends AbstractController $this->addFlash('booking_number', $bookingResponse->transactionNumber); $this->bookingService->clearBookingCreateDto($request); - return $this->redirectToRoute('app_booking_success'); + return $this->hxRedirect($request, $this->generateUrl('app_booking_success')); } catch (\Exception $e) { $this->logger->error('Booking creation failed', [ 'exception' => $e->getMessage(), diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php index 9463a93..80e25e0 100644 --- a/src/Controller/Booking/EditController.php +++ b/src/Controller/Booking/EditController.php @@ -8,10 +8,10 @@ use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; use App\BusProNet\XmlLoader\PickupLoader; use App\Controller\Traits\BookingDataTrait; -use App\Controller\Traits\HtmxControllerTrait; use App\Entity\User; use App\Form\BookingEditType; use App\Form\Model\BookingDto; +use App\Htmx\HxTrait; use App\Security\Crypt; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; @@ -29,7 +29,7 @@ use Symfony\Contracts\Cache\CacheInterface; class EditController extends AbstractController { use BookingDataTrait; - use HtmxControllerTrait; + use HxTrait; public function __construct( private readonly ApiClient $apiClient, @@ -54,42 +54,22 @@ class EditController extends AbstractController $email = $user->getEmail(); $password = $this->crypt->decrypt($user->getPassword()); - // Fetch original booking data via API and cache result for a short ttl - $bookingData = $this->fetchBookingData($email, $password, $id); + // Load form data from session (or API on first load) + $formData = $this->loadFormData($request, $id, $email, $password); + if (null === $formData) { + return $this->redirectToRoute('app_bookings'); + } + // Fetch booking data for display (surcharges, canceled status, etc.) + $bookingData = $this->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); return $this->redirectToRoute('app_bookings'); } - $this->denyAccessUnlessGranted('EDIT', $bookingData); - - // Load according travel data - $travelData = $this->travelDataService->getTravelData($bookingData->dateId); - - if (null === $travelData) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return $this->redirectToRoute('app_bookings'); - } - - // Fetch mutability and availability information via service + // Fetch mutable data for form constraints $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); - - if (null === $mutableData || null === $availabilities) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return $this->redirectToRoute('app_bookings'); - } - - // Patch travel data with additional information from above - $this->travelDataService->patchAvailabilities($travelData, $availabilities); - $this->travelDataService->patchMutability($travelData, $mutableData); - - // Create DTO for form - $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); // Calculate pricing data for template $summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData); @@ -104,7 +84,13 @@ class EditController extends AbstractController ); $form = $this->createForm(BookingEditType::class, $formData, [ - 'attr' => ['novalidate' => 'novalidate'], + 'attr' => [ + 'novalidate' => 'novalidate', + 'hx-post' => $this->generateUrl('app_booking_edit', ['id' => $id]), + 'hx-target' => '#form-wrapper', + 'hx-select' => '#form-wrapper', + 'hx-swap' => 'outerHTML', + ], 'validation_groups' => ['booking_edit'], ]); @@ -136,6 +122,9 @@ class EditController extends AbstractController } catch (InvalidArgumentException $e) { } + // Clear session on successful save + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); $this->logger->info('Booking update successful', [ @@ -143,11 +132,13 @@ class EditController extends AbstractController 'booking_id' => $id, ]); - return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } } catch (ApiClientException $e) { $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); } + + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } return $this->render('booking/edit.html.twig', [ @@ -163,6 +154,21 @@ class EditController extends AbstractController ]); } + /** + * Reloads booking data from API, discarding all session changes. + */ + #[Route('/bookings/{id}/edit/reload', name: 'app_booking_edit_reload', requirements: ['id' => '\d+'], methods: ['POST'])] + #[IsGranted('ROLE_USER')] + public function reloadFromApi(int $id, Request $request): Response + { + // Clear session to discard all changes + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + + $this->addFlash('success', 'Änderungen verworfen, Daten neu geladen'); + + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + } + /** * HTMX endpoint for refreshing the participant form without validation. */ @@ -170,43 +176,20 @@ class EditController extends AbstractController #[IsGranted('ROLE_USER')] public function refreshParticipantForm(int $id, Request $request): Response { - /** @var User $user */ - $user = $this->getUser(); - $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); + // Load form data from session + $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); - // Fetch original booking data via API and cache result for a short ttl - $bookingData = $this->fetchBookingData($email, $password, $id); - - if (null === $bookingData || $bookingData instanceof Notification) { - return new Response('Buchungsdaten nicht verfügbar', Response::HTTP_BAD_REQUEST); + if (null === $formData) { + return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); } - $this->denyAccessUnlessGranted('EDIT', $bookingData); - - // Load travel data - $travelData = $this->travelDataService->getTravelData($bookingData->dateId); - - if (null === $travelData) { - return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST); + // Refresh availability data + $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); } - // Fetch mutability and availability information - $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - $availabilities = $this->travelDataService->getAvailabilityDataCached($bookingData->dateId); - - if (null === $mutableData || null === $availabilities) { - return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST); - } - - // Patch travel data - $this->travelDataService->patchAvailabilities($travelData, $availabilities); - $this->travelDataService->patchMutability($travelData, $mutableData); - - // Create fresh DTO from booking data - $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); - - // Process form data without validation to capture current state + // Process form without validation to capture current state $form = $this->createForm(BookingEditType::class, $formData, [ 'attr' => ['novalidate' => 'novalidate'], 'validation_groups' => false, @@ -214,9 +197,23 @@ class EditController extends AbstractController $form->handleRequest($request); + // Save updated DTO back to session + $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); + // Collect notifications from all participants $notifications = $this->collectParticipantNotifications($formData); + // Fetch booking data and mutable data for display + /** @var User $user */ + $user = $this->getUser(); + $email = $user->getEmail(); + $password = $this->crypt->decrypt($user->getPassword()); + + $bookingData = $this->fetchBookingData($email, $password, $id); + $mutableData = null !== $bookingData && !($bookingData instanceof Notification) + ? $this->travelDataService->getMutabilityData($bookingData->dateId) + : null; + // Calculate pricing and summary data $summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData); $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($formData); @@ -277,4 +274,90 @@ class EditController extends AbstractController return $notifications; } + + /** + * Loads form data from session or initializes from API on first load. + * + * @return BookingDto|null The form data, or null on error + */ + private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto + { + // Try to load from session first + $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + + if (null === $formData) { + // First load: initialize from API + return $this->initializeFromApi($request, $bookingId, $email, $password); + } + + // Subsequent load: refresh from session with staleness check + return $this->refreshFromSession($formData); + } + + /** + * Initializes form data from API on first load and stores in session. + * + * @return BookingDto|null The form data, or null on error + */ + private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto + { + $bookingData = $this->fetchBookingData($email, $password, $bookingId); + + if (null === $bookingData || $bookingData instanceof Notification) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + + return null; + } + + $this->denyAccessUnlessGranted('EDIT', $bookingData); + + $travelData = $this->travelDataService->getTravelData($bookingData->dateId); + if (null === $travelData) { + $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); + + return null; + } + + $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); + $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); + + if (null === $mutableData || null === $availabilities) { + $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); + + return null; + } + + $this->travelDataService->patchAvailabilities($travelData, $availabilities); + $this->travelDataService->patchMutability($travelData, $mutableData); + + $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); + $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); + + return $formData; + } + + /** + * Refreshes form data loaded from session with latest availability. + * + * @return BookingDto The refreshed form data + */ + private function refreshFromSession(BookingDto $formData): BookingDto + { + // Refresh availability data + $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); + } + + // Show staleness warning if session is older than 5 minutes + if (null !== $formData->lastSessionUpdate) { + $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); + if ($ageInSeconds > 300) { + $minutes = (int) ceil($ageInSeconds / 60); + $this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes)); + } + } + + return $formData; + } } diff --git a/src/Controller/Traits/HtmxControllerTrait.php b/src/Controller/Traits/HtmxControllerTrait.php deleted file mode 100644 index 818d200..0000000 --- a/src/Controller/Traits/HtmxControllerTrait.php +++ /dev/null @@ -1,37 +0,0 @@ - $context the context to pass to the template - */ - protected function htmxOobResponse(string $templateName, array $blockNames, array $context = []): Response - { - $html = ''; - // Add a flag to the context so templates can conditionally add the hx-swap-oob attribute. - $oobContext = $context + ['htmx_oob_swap' => true]; - - foreach ($blockNames as $blockName) { - // Use renderBlockView() to get the raw HTML string for each block. - $html .= $this->renderBlockView($templateName, $blockName, $oobContext); - } - - return new Response($html); - } -} diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 9a1f453..54d2f97 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -58,6 +58,12 @@ class BookingDto */ public ?Booking $booking = null; + /** + * Timestamp of last session update (for staleness detection). + * Updated automatically by BookingService::saveBookingDto(). + */ + public ?\DateTimeImmutable $lastSessionUpdate = null; + public function __construct(public Travel $travel, public int $hotelId) { } diff --git a/src/Htmx/HxRedirectResponse.php b/src/Htmx/HxRedirectResponse.php new file mode 100644 index 0000000..1570ced --- /dev/null +++ b/src/Htmx/HxRedirectResponse.php @@ -0,0 +1,32 @@ + $url, + ]; + + if (null !== $retarget) { + $headers['HX-Retarget'] = $retarget; + } + + return parent::__construct(null, Response::HTTP_OK, $headers); + } +} diff --git a/src/Htmx/HxRefreshResponse.php b/src/Htmx/HxRefreshResponse.php new file mode 100644 index 0000000..8697029 --- /dev/null +++ b/src/Htmx/HxRefreshResponse.php @@ -0,0 +1,21 @@ + 'true']); + } +} diff --git a/src/Htmx/HxStopPollingResponse.php b/src/Htmx/HxStopPollingResponse.php new file mode 100644 index 0000000..83a78b1 --- /dev/null +++ b/src/Htmx/HxStopPollingResponse.php @@ -0,0 +1,23 @@ +headers->get('HX-Request'); + } + + /** + * Renders a template with HTMX-aware block selection. + * + * Renders either a specific template block or the full template based on + * whether the request is an HTMX request. When an HTMX request is detected + * and a block is specified, only that block is rendered. Otherwise, the + * full template is rendered. This enables efficient partial page updates + * for HTMX requests while maintaining full page rendering for regular requests. + * + * @param Request $request The current HTTP request + * @param string $template The template name to render + * @param array $parameters Template parameters to pass to the view + * @param string|null $block The specific block to render for HTMX requests + * + * @return Response The rendered response + */ + public function hxRender( + Request $request, + string $template, + array $parameters = [], + ?string $block = null, + ): Response { + if (null !== $block && true === $this->isHxRequest($request)) { + return $this->renderBlock($template, $block, $parameters); + } + + return $this->render($template, $parameters); + } + + /** + * Performs an HTMX-aware redirect. + * + * Returns an HTMX-specific redirect response when the request is an HTMX + * request, or a standard redirect response for regular HTTP requests. + * HTMX redirects are handled differently by the client, allowing for + * smoother user experiences in single-page application contexts. + * + * @param Request $request The current HTTP request + * @param string $url The URL to redirect to + * + * @return Response Either an HxRedirectResponse or RedirectResponse + */ + public function hxRedirect(Request $request, string $url): Response + { + if (true === $this->isHxRequest($request)) { + return new HxRedirectResponse($url); + } + + return new RedirectResponse($url); + } + + /** + * Renders multiple template blocks in a single response. + * + * Combines multiple template blocks into a single response content. This is + * useful for HTMX requests that need to update multiple parts of the page + * simultaneously. Each block in the array should contain 'template', 'block', + * and 'parameters' keys to define what to render. + * + * @param array $blocks Array of block definitions, each containing: + * - 'template': The template name + * - 'block': The block name to render + * - 'parameters': Template parameters + * + * @return Response Response containing all rendered blocks concatenated + */ + public function hxRenderBlocks(array $blocks): Response + { + $content = ''; + + foreach ($blocks as $block) { + $content .= $this->renderBlockView($block['template'], $block['block'], $block['parameters']); + } + + return new Response($content); + } + + /** + * Renders multiple Twig blocks for an HTMX Out-of-Band swap response. + * + * This method is specifically designed for HTMX OOB (Out-of-Band) swaps, + * where multiple blocks need to be updated simultaneously. It adds an + * `htmx_oob_swap` flag to the template context, allowing templates to + * conditionally add the hx-swap-oob attribute to elements. + * + * @param string $templateName The name of the Twig template + * @param string[] $blockNames An array of block names to render + * @param array $context The context to pass to the template + * + * @return Response Response containing all rendered blocks with OOB context + */ + protected function htmxOobResponse(string $templateName, array $blockNames, array $context = []): Response + { + $html = ''; + // Add a flag to the context so templates can conditionally add the hx-swap-oob attribute. + $oobContext = $context + ['htmx_oob_swap' => true]; + + foreach ($blockNames as $blockName) { + // Use renderBlockView() to get the raw HTML string for each block. + $html .= $this->renderBlockView($templateName, $blockName, $oobContext); + } + + return new Response($html); + } +} diff --git a/src/Htmx/HxTriggerResponse.php b/src/Htmx/HxTriggerResponse.php new file mode 100644 index 0000000..bc16095 --- /dev/null +++ b/src/Htmx/HxTriggerResponse.php @@ -0,0 +1,31 @@ + $trigger]; + + if (true === $disableSwap) { + $headers['HX-Reswap'] = 'none'; + } + + return parent::__construct($content, Response::HTTP_OK, $headers); + } +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index bfe455e..01f54db 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -86,14 +86,17 @@ class BookingService /** * Saves the booking DTO to the session. * - * @param Request $request The HTTP request with session - * @param object $bookingDto The booking DTO to persist - * @param string $mode The booking mode (create/edit) + * @param Request $request The HTTP request with session + * @param BookingDto $bookingDto The booking DTO to persist + * @param string $mode The booking mode (create/edit) */ - public function saveBookingDto(Request $request, object $bookingDto, string $mode): void + public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void { - $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; - $request->getSession()->set($key, $bookingDto); + // Track last session update for staleness detection + $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); + + $sessionKey = $this->getSessionKey($mode); + $request->getSession()->set($sessionKey, $bookingDto); } /** @@ -102,13 +105,18 @@ class BookingService * @param Request $request The HTTP request containing session data * @param string $mode The booking mode (create/edit) * - * @return object|null The booking DTO from session or null if not found + * @return BookingDto|null The booking DTO from session or null if not found */ - public function getBookingDto(Request $request, string $mode): ?object + public function getBookingDto(Request $request, string $mode): ?BookingDto { - $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; + $sessionKey = $this->getSessionKey($mode); + $session = $request->getSession(); - return $request->getSession()->get($key); + if (false === $session->has($sessionKey)) { + return null; + } + + return $session->get($sessionKey); } /** @@ -119,8 +127,20 @@ class BookingService */ public function clearBookingDto(Request $request, string $mode): void { - $key = BookingDto::MODE_CREATE === $mode ? self::BOOKING_CREATE_KEY : self::BOOKING_EDIT_KEY; - $request->getSession()->remove($key); + $sessionKey = $this->getSessionKey($mode); + $request->getSession()->remove($sessionKey); + } + + /** + * Generates session key based on mode. + * + * @param string $mode 'create' or 'edit' + * + * @return string The session key + */ + private function getSessionKey(string $mode): string + { + return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY; } /** diff --git a/templates/_partials/_loading_indicator.html.twig b/templates/_partials/_loading_indicator.html.twig index 058a998..e368ce3 100644 --- a/templates/_partials/_loading_indicator.html.twig +++ b/templates/_partials/_loading_indicator.html.twig @@ -1,4 +1,4 @@ - diff --git a/templates/booking/create_step_1.html.twig b/templates/booking/create_step_1.html.twig index d54b215..2473647 100644 --- a/templates/booking/create_step_1.html.twig +++ b/templates/booking/create_step_1.html.twig @@ -17,56 +17,62 @@ {% endif %}
-
+

Unterkunft

{{ form_start(form) }} - {{ form_errors(form) }} - {% if groupedRooms.by_room is not empty %} -

- Zimmer -

- {% for roomId, room in groupedRooms.by_room %} - {{ form_row(form.roomSelections[roomId], { - 'attr': { - 'hx-post': path('app_booking_create_step_1_room_summary'), - 'hx-target': '#booking-summary', - 'hx-swap': 'innerHTML', - 'hx-trigger': 'change' - } - }) }} - {% endfor %} - {% endif %} - {% if groupedRooms.by_pax is not empty %} -

- Betten -

- {% for roomId, room in groupedRooms.by_pax %} - {{ form_row(form.roomSelections[roomId], { - 'attr': { - 'hx-post': path('app_booking_create_step_1_room_summary'), - 'hx-target': '#booking-summary', - 'hx-swap': 'innerHTML', - 'hx-trigger': 'change' - } - }) }} - {% endfor %} - {% endif %} + {% do form.roomSelections.setRendered %} + {# This block contains the room selection form fields #} + {% block room_selection_form %} +
+ {{ form_errors(form) }} + {% if groupedRooms.by_room is not empty %} +

+ Zimmer +

+ {% for roomId, room in groupedRooms.by_room %} + {{ form_row(form.roomSelections[roomId], { + 'attr': { + 'hx-post': path('app_booking_create_step_1_refresh'), + 'hx-swap': 'none', + 'hx-trigger': 'change' + } + }) }} + {% endfor %} + {% endif %} + {% if groupedRooms.by_pax is not empty %} +

+ Betten +

+ {% for roomId, room in groupedRooms.by_pax %} + {{ form_row(form.roomSelections[roomId], { + 'attr': { + 'hx-post': path('app_booking_create_step_1_refresh'), + 'hx-swap': 'none', + 'hx-trigger': 'change' + } + }) }} + {% endfor %} + {% endif %} +
+ {% endblock %}
- +
{{ form_rest(form) }} {{ form_end(form) }}
-
- {% include 'booking/_summary.html.twig' with { - 'bookingCreateDto': bookingCreateDto, - 'participantCount': participantCount, - 'pricingData': pricingData, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': [] - } %} -
+ {% block booking_summary %} +
+ {% include 'booking/_summary.html.twig' with { + 'bookingCreateDto': bookingCreateDto, + 'participantCount': participantCount, + 'pricingData': pricingData, + 'groupedSelectedRooms': groupedSelectedRooms, + 'assignmentCounts': [] + } %} +
+ {% endblock %}
{% endblock %} diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 5ef6eb1..109892f 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -55,7 +55,7 @@

Neue Buchung

-
+

Teilnehmer

{{ form_start(form) }} {% do form.participants.setRendered %} @@ -318,7 +318,7 @@ {% endblock %}
Zurück - +
{{ form_rest(form) }} {{ form_end(form) }} diff --git a/templates/booking/create_step_3.html.twig b/templates/booking/create_step_3.html.twig index 6e75580..9177685 100644 --- a/templates/booking/create_step_3.html.twig +++ b/templates/booking/create_step_3.html.twig @@ -27,10 +27,18 @@

Neue Buchung

-
+

Zahlungsart

- {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + {{ form_start(form, { + 'attr': { + 'novalidate': 'novalidate', + 'hx-post': path('app_booking_create_step_3'), + 'hx-target': '#form-wrapper', + 'hx-select': '#form-wrapper', + 'hx-swap': 'outerHTML' + } + }) }} {# Payment method selection with HTMX #}
Zurück - +
{{ form_end(form) }} diff --git a/templates/booking/create_step_4.html.twig b/templates/booking/create_step_4.html.twig index bf6b1a2..8ed5e0e 100644 --- a/templates/booking/create_step_4.html.twig +++ b/templates/booking/create_step_4.html.twig @@ -21,7 +21,7 @@
{% endif %} -
+

Buchung bestätigen

{# Travel Summary #} @@ -290,7 +290,15 @@
{# Confirmation Form #} - {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + {{ form_start(form, { + 'attr': { + 'novalidate': 'novalidate', + 'hx-post': path('app_booking_create_step_4'), + 'hx-target': '#form-wrapper', + 'hx-select': '#form-wrapper', + 'hx-swap': 'outerHTML' + } + }) }}
{{ form_row(form.confirmationAccepted, { @@ -300,7 +308,7 @@
Zurück - +
{{ form_end(form) }} diff --git a/templates/booking/edit.html.twig b/templates/booking/edit.html.twig index 1b3c72c..345d1bc 100644 --- a/templates/booking/edit.html.twig +++ b/templates/booking/edit.html.twig @@ -53,11 +53,23 @@ {% block content %} {% include '_partials/_flashes.html.twig' %}
-

Buchung bearbeiten

+ + {# Header with reload button #} +
+

Buchung bearbeiten

+ + +
{# Grid layout with 2/3 form + 1/3 summary #}
-
+

Teilnehmer

{{ form_start(form) }} {% do form.participants.setRendered %} @@ -362,7 +374,7 @@ {% endblock %}
Zurück - +
{{ form_rest(form) }} {{ form_end(form) }} @@ -379,4 +391,4 @@
{% endblock %}
-{% endblock %} \ No newline at end of file +{% endblock %}