feat: refactor to cards

This commit is contained in:
Björn Fromme committed 2026-03-16 11:59:10 +01:00
1 parent 32a9fac9ed
commit e51c4843c5
40 files changed
+2639 -3097

No files matched your search

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Form\Model\BookingDto;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Provides common functionality for booking creation controllers.
*
* This trait contains shared validation and redirect logic used across
* all booking creation steps to ensure consistent behavior and reduce
* code duplication.
*/
trait BookingCreateTrait
{
/**
* Validates step access and returns redirect response if necessary.
*
* @return RedirectResponse|null Returns redirect response if validation fails, null if access is allowed
*/
private function validateStepAccess(BookingDto $bookingCreateDto, int $expectedStep): ?RedirectResponse
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
* Redirects to the current step based on the DTO's currentStep.
*/
private function redirectToCurrentStep(BookingDto $bookingCreateDto): RedirectResponse
{
$route = match ($bookingCreateDto->currentStep) {
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
4 => 'app_booking_create_step_4',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route);
}
/**
* Returns the total number of participants based on room selections.
*/
private function getParticipantsCount(BookingDto $bookingCreateDto): int
{
return $this
->bookingService
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
}
/**
* Prepares template variables for the booking summary sidebar.
*
* @return array<string, mixed> Array containing all variables needed for the summary partial
*/
private function getSummaryVariables(BookingDto $bookingCreateDto): array
{
$participantsCount = $this->getParticipantsCount($bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms);
return [
'participantsCount' => $participantsCount,
'pricingData' => $summary['pricing'],
'assignmentCounts' => $roomAssignmentCounts,
'groupedSelectedRooms' => $groupedSelectedRooms,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Controller\Booking\Traits;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\ItemInterface;
trait BookingDataTrait
{
public function fetchBookingData(string $email, string $password, int $id): Booking|Notification|null
{
$cacheKey = sprintf('bpn_booking_%d', $id);
try {
$bookingData = $this->cache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $id) {
$item->expiresAfter(300);
return $this->apiClient->getBooking($email, $password, $id);
});
} catch (InvalidArgumentException $e) {
$bookingData = null;
}
return $bookingData;
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException;
use App\Form\Model\BookingDto;
use App\Service\BookingService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Provides centralized exception handling for booking controllers.
*
* This trait contains common exception handling logic for booking operations
* including user-friendly error messages and appropriate redirects.
*/
trait BookingExceptionHandlerTrait
{
/**
* Safely retrieves booking DTO with centralized exception handling.
*
* Handles all booking-related exceptions and provides appropriate user feedback
* by redirecting to the error page with flash messages.
*/
protected function getOrCreateBookingCreateDto(BookingService $bookingService, Request $request): BookingDto|RedirectResponse
{
try {
return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException $e) {
$this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.');
return $this->redirectToRoute('app_booking_create_error');
} catch (TravelNotFoundException $e) {
$this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotFoundException $e) {
$this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotInTravelException $e) {
$this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.');
return $this->redirectToRoute('app_booking_create_error');
} catch (NoRoomsAvailableException $e) {
$this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.');
return $this->redirectToRoute('app_booking_create_error');
}
}
/**
* Safely retrieves booking DTO for HTMX requests with lightweight error responses.
*
* Returns empty 400 responses for HTMX requests when exceptions occur,
* allowing the frontend to handle errors appropriately.
*/
protected function getOrCreateBookingCreateDtoForHtmx(BookingService $bookingService, Request $request): mixed
{
try {
return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
return new Response('', 400);
}
}
}
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Form\BookingParticipantType;
use App\Form\Model\BookingDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
use App\Service\ParticipantCardDataService;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Shared controller logic for participant card-based booking flows.
*
* This trait provides common functionality for both create and edit controllers
* that use the card-based UI pattern (card overview + lazy-loaded forms).
*/
trait ParticipantCardFlowTrait
{
/**
* Load BookingDto from session or throw exception.
*
* @throws \RuntimeException When booking data not found in session
*/
private function loadBookingDtoOrFail(Request $request, string $mode): BookingDto
{
$bookingDto = $this->bookingService->getBookingDto($request, $mode);
if (null === $bookingDto) {
throw new \RuntimeException(sprintf('Booking data not found in session for mode: %s', $mode));
}
return $bookingDto;
}
/**
* Generate card data for all participants.
*
* @return array<int, array{name: string, roomName: string, price: string}>
*/
private function generateAllCardsData(BookingDto $bookingDto): array
{
return $this->participantCardService->getAllCardsData($bookingDto);
}
/**
* Create form for single participant.
*
* This creates an autonomous participant form with booking_context option
* so it can process field handlers independently.
*/
private function createParticipantForm(
BookingDto $bookingDto,
int $index,
array $options = [],
): FormInterface {
$participant = $bookingDto->participants[$index] ?? null;
if (null === $participant) {
throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index));
}
// Merge default options with provided options
$formOptions = array_merge([
'booking_context' => $bookingDto,
'edit_mode' => BookingDto::MODE_EDIT === $bookingDto->getMode(),
], $options);
return $this->createForm(BookingParticipantType::class, $participant, $formOptions);
}
/**
* Calculate summary data (pricing, room counts, etc.).
*
* @return array{
* participantsCount: int,
* totalPrice: string,
* groupedSelectedRooms: array,
* assignmentCounts: array
* }
*/
private function calculateSummaryData(BookingDto $bookingDto): array
{
// Calculate individual prices for all participants
$participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto);
// Calculate total price
$totalPrice = array_sum($participantPrices);
// Get room assignment counts
$roomCounts = [];
foreach ($bookingDto->participants as $participant) {
if (null !== $participant->assignedRoomId) {
$roomCounts[$participant->assignedRoomId] = ($roomCounts[$participant->assignedRoomId] ?? 0) + 1;
}
}
// Group selected rooms with counts
$groupedSelectedRooms = [];
foreach ($roomCounts as $roomId => $count) {
$room = $bookingDto->travel->getRoomById($roomId);
if (null !== $room) {
$groupedSelectedRooms[] = [
'room' => $room,
'count' => $count,
];
}
}
return [
'participantsCount' => count($bookingDto->participants),
'totalPrice' => number_format($totalPrice, 2, ',', '.').' €',
'groupedSelectedRooms' => $groupedSelectedRooms,
'assignmentCounts' => $roomCounts,
];
}
/**
* Process single participant form refresh.
*
* Handles HTMX form refresh without validation, updates sidebar via OOB swap.
*/
private function handleParticipantRefresh(
Request $request,
BookingDto $bookingDto,
int $index,
string $refreshRouteName,
string $submitRouteName,
): Response {
// Create form with validation disabled
$form = $this->createParticipantForm($bookingDto, $index, [
'validation_groups' => false,
]);
$form->handleRequest($request);
// Save updated booking data to session
$this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode());
// Collect notifications from participant DTO
$participant = $bookingDto->participants[$index] ?? null;
$notifications = $participant?->notifications ?? [];
// Clear notifications after collecting
if (null !== $participant) {
$participant->notifications = [];
}
// Calculate summary data for sidebar
$summaryData = $this->calculateSummaryData($bookingDto);
// Get detailed pricing data for summary sidebar
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto);
// Render form and sidebar with OOB swap using htmxOobResponse
// This renders ONLY the specific blocks, not the entire template
$response = $this->htmxOobResponse(
'booking/_participant_form_standalone.html.twig',
['participant_form', 'booking_summary'],
[
'form' => $form->createView(),
'participantIndex' => $index,
'bookingDto' => $bookingDto,
'summaryData' => $summaryData,
'pricingData' => $summary['pricing'],
'refreshRouteName' => $refreshRouteName,
'submitRouteName' => $submitRouteName,
]
);
// Add notifications to HX-Trigger header if present
if (false === empty($notifications)) {
$response->headers->set('HX-Trigger', json_encode([
'showNotifications' => $notifications,
]));
}
return $response;
}
/**
* Required services - implementing controllers must inject these.
*
* Controllers using this trait must have the following properties:
* - BookingService $bookingService
* - ParticipantCardDataService $participantCardService
* - BookingPriceCalculatorService $priceCalculator
*/
abstract private function createForm(string $type, $data = null, array $options = []): FormInterface;
abstract private function render(string $view, array $parameters = [], ?Response $response = null): Response;
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
/**
* Provides participant validation error extraction for card-based booking flows.
*
* Shared between CreateStep2Controller and EditController to identify which
* participants have validation errors that should be displayed on their cards.
*/
trait ParticipantValidationTrait
{
/**
* Extracts participant indices that have validation errors.
*
* Parses form errors to identify which participants have validation issues.
* Returns an array of participant indices (e.g., [0, 2, 5]).
*
* @return array<int> Array of participant indices with errors
*/
private function extractParticipantErrorIndices($form): array
{
$errorIndices = [];
$errors = $form->getErrors(true); // Get all errors recursively
foreach ($errors as $error) {
$propertyPath = $error->getCause()?->getPropertyPath();
if (null === $propertyPath) {
continue;
}
// Property paths look like "participants[0].firstName" or "participants[1].email"
if (preg_match('/participants\[(\d+)]/', $propertyPath, $matches)) {
$index = (int) $matches[1];
$errorIndices[$index] = true; // Use array key to avoid duplicates
}
}
return array_keys($errorIndices);
}
}