feat: refactor to cards
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user