feat: htmx powered requests, improved loading indicator
feat: loading indicator and htmx form submit
This commit is contained in:
@@ -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'})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Provides helper methods for handling HTMX responses.
|
||||
*
|
||||
* This trait should be used in controllers that extend Symfony's AbstractController,
|
||||
* as it relies on the renderBlockView() method.
|
||||
*/
|
||||
trait HtmxControllerTrait
|
||||
{
|
||||
/**
|
||||
* Renders multiple Twig blocks for an HTMX Out-of-Band swap response.
|
||||
*
|
||||
* @param string $templateName the name of the Twig template
|
||||
* @param string[] $blockNames an array of block names to render
|
||||
* @param array<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Htmx;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HxRedirectResponse extends Response
|
||||
{
|
||||
/**
|
||||
* Creates an HTMX redirect response.
|
||||
*
|
||||
* Constructs a response that instructs the HTMX client to navigate to the
|
||||
* specified URL. The response includes the HX-Redirect header with the target
|
||||
* URL. Optionally includes HX-Retarget header if a different target element
|
||||
* is specified for the response content.
|
||||
*
|
||||
* @param string $url The URL to redirect to
|
||||
* @param string|null $retarget Optional CSS selector for the target element
|
||||
*/
|
||||
public function __construct(string $url, ?string $retarget = null)
|
||||
{
|
||||
$headers = [
|
||||
'HX-Redirect' => $url,
|
||||
];
|
||||
|
||||
if (null !== $retarget) {
|
||||
$headers['HX-Retarget'] = $retarget;
|
||||
}
|
||||
|
||||
return parent::__construct(null, Response::HTTP_OK, $headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Htmx;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HxRefreshResponse extends Response
|
||||
{
|
||||
/**
|
||||
* Creates an HTMX refresh response.
|
||||
*
|
||||
* Constructs a response that instructs the HTMX client to perform a full page
|
||||
* refresh. The response includes the HX-Refresh header set to 'true', which
|
||||
* signals the HTMX client to reload the entire page. This response typically
|
||||
* contains no content since the page will be completely reloaded.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
return parent::__construct(null, Response::HTTP_OK, ['HX-Refresh' => 'true']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Htmx;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HxStopPollingResponse extends Response
|
||||
{
|
||||
/**
|
||||
* Creates an HTMX stop polling response.
|
||||
*
|
||||
* Constructs a response that instructs the HTMX client to stop polling. The response
|
||||
* includes the specified content and uses HTTP status code 286, which is recognized
|
||||
* by HTMX as a signal to terminate polling. This allows the server to control
|
||||
* client-side polling behavior and prevent unnecessary network requests.
|
||||
*
|
||||
* @param string $content The response content to send to the client
|
||||
*/
|
||||
public function __construct(string $content)
|
||||
{
|
||||
return parent::__construct($content, 286);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Htmx;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Trait providing HTMX-specific functionality for controllers.
|
||||
*
|
||||
* This trait provides methods to handle HTMX requests and responses, including
|
||||
* conditional rendering based on HTMX headers, HTMX-specific redirects, and
|
||||
* multi-block rendering capabilities. It simplifies the integration of HTMX
|
||||
* functionality into Symfony controllers by providing common patterns for
|
||||
* detecting HTMX requests and generating appropriate responses.
|
||||
*/
|
||||
trait HxTrait
|
||||
{
|
||||
/**
|
||||
* Determines if the current request is an HTMX request.
|
||||
*
|
||||
* Checks the HX-Request header to identify if the request was made via HTMX.
|
||||
* This allows controllers to provide different responses for HTMX vs regular
|
||||
* HTTP requests, enabling progressive enhancement patterns.
|
||||
*
|
||||
* @param Request $request The current HTTP request
|
||||
*
|
||||
* @return bool True if the request is an HTMX request, false otherwise
|
||||
*/
|
||||
public function isHxRequest(Request $request): bool
|
||||
{
|
||||
return 'true' === $request->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Htmx;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HxTriggerResponse extends Response
|
||||
{
|
||||
/**
|
||||
* Creates an HTMX trigger response with custom event triggering capabilities.
|
||||
*
|
||||
* Constructs a response that will trigger a custom JavaScript event on the client
|
||||
* when the HTMX request completes. The trigger can be a simple event name or a
|
||||
* JSON object for more complex event data. Optionally disables content swapping
|
||||
* to prevent the response content from being inserted into the DOM.
|
||||
*
|
||||
* @param string $content The response content (typically empty for trigger-only responses)
|
||||
* @param string $trigger The event name or JSON object to trigger on the client
|
||||
* @param bool $disableSwap Whether to disable HTMX content swapping (default: true)
|
||||
*/
|
||||
public function __construct(string $content, string $trigger, bool $disableSwap = true)
|
||||
{
|
||||
$headers = ['HX-Trigger' => $trigger];
|
||||
|
||||
if (true === $disableSwap) {
|
||||
$headers['HX-Reswap'] = 'none';
|
||||
}
|
||||
|
||||
return parent::__construct($content, Response::HTTP_OK, $headers);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="invisible fixed top-0 left-0 inset-0 z-10 bg-white/90 flex items-center justify-center"
|
||||
<div class="invisible fixed top-0 left-0 inset-0 z-10 bg-white/90 flex items-center justify-center transition-all duration-100"
|
||||
{{ stimulus_target('loading', 'indicator') }}>
|
||||
{% include '_partials/_spinner.html.twig' with { 'class': 'w-32 h-32 lg:w-48 lg:h-48'} %}
|
||||
</div>
|
||||
|
||||
@@ -17,56 +17,62 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<div id="form-wrapper" class="col-span-2">
|
||||
<h2 class="pb-4">
|
||||
Unterkunft
|
||||
</h2>
|
||||
{{ form_start(form) }}
|
||||
{{ form_errors(form) }}
|
||||
{% if groupedRooms.by_room is not empty %}
|
||||
<h3>
|
||||
Zimmer
|
||||
</h3>
|
||||
{% 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 %}
|
||||
<h3>
|
||||
Betten
|
||||
</h3>
|
||||
{% 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 %}
|
||||
<div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||
{{ form_errors(form) }}
|
||||
{% if groupedRooms.by_room is not empty %}
|
||||
<h3>
|
||||
Zimmer
|
||||
</h3>
|
||||
{% 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 %}
|
||||
<h3>
|
||||
Betten
|
||||
</h3>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
|
||||
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
<div id="booking-summary">
|
||||
{% include 'booking/_summary.html.twig' with {
|
||||
'bookingCreateDto': bookingCreateDto,
|
||||
'participantCount': participantCount,
|
||||
'pricingData': pricingData,
|
||||
'groupedSelectedRooms': groupedSelectedRooms,
|
||||
'assignmentCounts': []
|
||||
} %}
|
||||
</div>
|
||||
{% block booking_summary %}
|
||||
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||
{% include 'booking/_summary.html.twig' with {
|
||||
'bookingCreateDto': bookingCreateDto,
|
||||
'participantCount': participantCount,
|
||||
'pricingData': pricingData,
|
||||
'groupedSelectedRooms': groupedSelectedRooms,
|
||||
'assignmentCounts': []
|
||||
} %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div {{ stimulus_controller('toast') }}></div>
|
||||
<h1>Neue Buchung</h1>
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<div id="form-wrapper" class="col-span-2">
|
||||
<h2>Teilnehmer</h2>
|
||||
{{ form_start(form) }}
|
||||
{% do form.participants.setRendered %}
|
||||
@@ -318,7 +318,7 @@
|
||||
{% endblock %}
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
|
||||
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
|
||||
@@ -27,10 +27,18 @@
|
||||
<h1>Neue Buchung</h1>
|
||||
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<div id="form-wrapper" class="col-span-2">
|
||||
<h2 class="mb-6">Zahlungsart</h2>
|
||||
|
||||
{{ 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 #}
|
||||
<div id="form-payment"
|
||||
@@ -63,7 +71,7 @@
|
||||
|
||||
<div class="flex justify-between pt-6">
|
||||
<a href="{{ path('app_booking_create_step_2') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
|
||||
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<div id="form-wrapper">
|
||||
<h2 class="mb-6">Buchung bestätigen</h2>
|
||||
|
||||
{# Travel Summary #}
|
||||
@@ -290,7 +290,15 @@
|
||||
</div>
|
||||
|
||||
{# 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'
|
||||
}
|
||||
}) }}
|
||||
|
||||
<div class="mb-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
{{ form_row(form.confirmationAccepted, {
|
||||
@@ -300,7 +308,7 @@
|
||||
|
||||
<div class="flex justify-between pt-6">
|
||||
<a href="{{ path('app_booking_create_step_3') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||
<button type="submit" class="button bg-button bg-button--primary">Verbindlich buchen</button>
|
||||
<button type="submit" class="button bg-button bg-button--primary" {{ stimulus_action('loading', 'toggle') }}>Verbindlich buchen</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
@@ -53,11 +53,23 @@
|
||||
{% block content %}
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
<div {{ stimulus_controller('toast') }}></div>
|
||||
<h1 class="text-3xl font-semibold pb-8">Buchung bearbeiten</h1>
|
||||
|
||||
{# Header with reload button #}
|
||||
<div class="flex justify-between items-center pb-8">
|
||||
<h1 class="text-3xl font-semibold">Buchung bearbeiten</h1>
|
||||
|
||||
<button type="button"
|
||||
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}"
|
||||
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?"
|
||||
{{ stimulus_action('loading', 'toggle') }}
|
||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded transition-colors">
|
||||
🔄 Änderungen verwerfen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Grid layout with 2/3 form + 1/3 summary #}
|
||||
<div class="grid grid-cols-3 gap-8">
|
||||
<div class="col-span-2">
|
||||
<div id="form-wrapper" class="col-span-2">
|
||||
<h2>Teilnehmer</h2>
|
||||
{{ form_start(form) }}
|
||||
{% do form.participants.setRendered %}
|
||||
@@ -362,7 +374,7 @@
|
||||
{% endblock %}
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||
<button type="submit" class="button bg-button bg-button--secondary">Aktualisieren</button>
|
||||
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Aktualisieren</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
@@ -379,4 +391,4 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user