feat: implement screendesign

This commit is contained in:
Björn Fromme
2025-12-06 15:02:33 +01:00
parent b206c3990b
commit bfb1a04ea0
84 changed files with 2807 additions and 2603 deletions
@@ -68,17 +68,30 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index;
// First participant (applicant): copy address from applicant if participant address is empty
// BPN API may return full address only in <anmelder> but minimal/empty address in <teilnehmer id="1">
// Only copy if first participant has no street (indicating empty/incomplete address)
// This allows applicant and first participant to be different people with different addresses
if (0 === $index && null !== $booking->applicant->address) {
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
// First participant (applicant): copy data from applicant if participant data is empty
// BPN API may return full data only in <anmelder> but minimal/empty data in <teilnehmer id="1">
if (0 === $index) {
// Copy address if first participant has no street (indicating empty/incomplete address)
// This allows applicant and first participant to be different people with different addresses
if (null !== $booking->applicant->address) {
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
@@ -304,6 +304,17 @@ class BookingPayloadBuilder
}
}
// Add body dimensions
if (null !== $participant->height) {
$participantData['sonstiges1'] = $participant->height;
}
if (null !== $participant->weight) {
$participantData['sonstiges2'] = $participant->weight;
}
if (null !== $participant->shoeSize) {
$participantData['sonstiges3'] = $participant->shoeSize;
}
// Add wishes (room remarks and license plate)
if (null !== $participant->remarksRoom || null !== $participant->licensePlate) {
$participantData['wünsche'] = [];
@@ -20,7 +20,7 @@ class PersonalDataSynchronizer
/**
* Updates participant personal data from form input.
*
* Only processes participants with status 'F' (active/confirmed participants).
* Processes all active participants (status 'F' or 'A'). Skips canceled participants (status 'S').
* Updates all personal data fields and communication information.
*
* IMPORTANT: The applicant's address must never be modified. This method updates
@@ -32,7 +32,8 @@ class PersonalDataSynchronizer
public function updateParticipantPersonalData(array $participants, Booking $bookingData): void
{
foreach ($participants as $participant) {
if ('F' !== $participant->status) {
// Skip canceled participants (status 'S')
if ('S' === $participant->status) {
continue;
}
+6 -1
View File
@@ -27,7 +27,7 @@ class PersonalData
public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
public string $firstName = '';
public ?string $firstName = '';
public ?string $salutation = null;
public ?string $title = null;
@@ -55,6 +55,11 @@ class PersonalData
$this->communication = new Communication();
}
public function getFullName(): string
{
return sprintf('%s %s', $this->firstName, $this->name);
}
/**
* Converts the personal data to API payload format.
*
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Controller\Account;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
#[Route('/account', name: 'app_account')]
#[IsGranted('ROLE_USER')]
public function index(): Response
{
return $this->render('account/index.html.twig');
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\Controller;
namespace App\Controller\Account;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
@@ -98,7 +98,7 @@ class PersonalDataController extends AbstractController
return $this->redirectToRoute('app_personal_data');
}
return $this->render('personal_data/index.html.twig', [
return $this->render('account/personal_data.html.twig', [
'personalData' => $personalData,
'personalDataForm' => $personalDataForm->createView(),
]);
@@ -9,6 +9,7 @@ use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException;
use App\Htmx\HxTrait;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -24,6 +25,8 @@ use Symfony\Component\Routing\Attribute\Route;
*/
class IndexController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly BookingService $bookingService,
private readonly AgencyLoader $agencyLoader,
@@ -113,18 +116,22 @@ class IndexController extends AbstractController
#[Route('/bookings/cancel', name: 'app_booking_cancel')]
public function cancel(Request $request): Response
{
// Clear the booking session
$this->bookingService->clearBookingSession($request);
if (Request::METHOD_POST === $request->getMethod()) {
// Clear the booking session
$this->bookingService->clearBookingSession($request);
// Clear the security target path to prevent redirect loop after login
// Without this, logging in after cancel would redirect back to a stale booking URL
$request->getSession()->remove('_security.main.target_path');
// Clear the security target path to prevent redirect loop after login
// Without this, logging in after cancel would redirect back to a stale booking URL
$request->getSession()->remove('_security.main.target_path');
// Add a flash message to inform the user
$this->addFlash('info', 'Buchung abgebrochen.');
// Add a flash message to inform the user
$this->addFlash('info', 'Buchung abgebrochen.');
// Redirect to login page
return $this->redirectToRoute('app_login');
// Redirect to login page
return $this->hxRedirect($request, $this->generateUrl('app_login'));
}
return $this->render('booking/modal_cancel.html.twig');
}
/**
+5 -1
View File
@@ -17,7 +17,8 @@ class SecurityController extends AbstractController
public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response
{
// Check if this is a booking flow (BookingDto exists in session)
$isBookingFlow = null !== $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY);
$bookingDto = $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY);
$isBookingFlow = null !== $bookingDto;
// If authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $isBookingFlow) {
@@ -52,6 +53,9 @@ class SecurityController extends AbstractController
return $this->render($template, [
'last_username' => $lastUsername,
'error' => $error,
'travel_title' => $bookingDto?->travel->label,
'travel_date_from' => $bookingDto?->travel->dateFrom,
'travel_date_to' => $bookingDto?->travel->dateTo,
]);
}
+5 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Model\BankAccount;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -11,11 +12,11 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class BankAccountDto
{
#[Assert\NotBlank(message: 'Bitte geben Sie Ihre IBAN ein.')]
#[Assert\NotBlank(message: 'Bitte gib deine IBAN ein.')]
#[Assert\Iban(message: 'Die eingegebene IBAN ist ungültig.')]
public ?string $iban = null;
#[Assert\NotBlank(message: 'Bitte geben Sie den Kontoinhaber ein.')]
#[Assert\NotBlank(message: 'Bitte gib den Kontoinhaber ein.')]
#[Assert\Length(
min: 2,
max: 70,
@@ -30,10 +31,10 @@ class BankAccountDto
)]
public ?string $bankName = null;
#[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')]
#[Assert\IsTrue(message: 'Bitte akzeptiere das SEPA-Mandat.')]
public bool $sepaMandateAccepted = false;
public static function fromBankAccount(\App\BusProNet\Model\BankAccount $bankAccount): static
public static function fromBankAccount(BankAccount $bankAccount): static
{
$instance = new static();
$instance->iban = $bankAccount->iban;
+5 -5
View File
@@ -41,7 +41,7 @@ class BookingDto
#[Assert\Choice(
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
message: 'Bitte wählen Sie eine gültige Zahlungsart.'
message: 'Bitte wähle eine gültige Zahlungsart.'
)]
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
@@ -255,7 +255,7 @@ class BookingDto
}
if (null === $this->bankAccount) {
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
$context->buildViolation('Bitte gib deine Bankverbindung an.')
->atPath('bankAccount')
->addViolation();
@@ -263,19 +263,19 @@ class BookingDto
}
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
$context->buildViolation('Bitte geben Sie Ihre IBAN ein.')
$context->buildViolation('Bitte gib deine IBAN ein.')
->atPath('bankAccount.iban')
->addViolation();
}
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
$context->buildViolation('Bitte geben Sie den Kontoinhaber ein.')
$context->buildViolation('Bitte gib den Kontoinhaber ein.')
->atPath('bankAccount.accountHolder')
->addViolation();
}
if (false === $this->bankAccount->sepaMandateAccepted) {
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.')
$context->buildViolation('Bitte akzeptiere das SEPA-Mandat.')
->atPath('bankAccount.sepaMandateAccepted')
->addViolation();
}
+4 -2
View File
@@ -15,7 +15,8 @@ class BookingSummaryDto
/**
* @param array<int, RoomSelectionDto> $selectedRooms Selected room DTOs from booking
* @param int $participantCount Total participant count from room capacity
* @param string $totalPrice Formatted total price (e.g., "1.234,56 €")
* @param float $totalPrice Total price before voucher deductions
* @param float $payableAmount Amount after voucher deductions
* @param array<array{room: mixed, count: int}> $groupedSelectedRooms Rooms grouped by participant assignments
* @param array<int, int> $assignmentCounts Room ID to participant count mapping
* @param array $pricingData Detailed pricing breakdown
@@ -24,7 +25,8 @@ class BookingSummaryDto
public function __construct(
public readonly array $selectedRooms,
public readonly int $participantCount,
public readonly string $totalPrice,
public readonly float $totalPrice,
public readonly float $payableAmount,
public readonly array $groupedSelectedRooms,
public readonly array $assignmentCounts,
public readonly array $pricingData,
-1
View File
@@ -35,7 +35,6 @@ class PersonalDataType extends AbstractType
->add('email', EmailType::class, [
'label' => 'E-Mail',
'property_path' => 'communication.email',
'sanitize_html' => true,
])
->add('phone', TextType::class, [
'label' => 'Telefon',
+1 -1
View File
@@ -29,10 +29,10 @@ class RegistrationType extends AbstractType
])
->add('name', TextType::class, [
'label' => 'Nachname',
'sanitize_html' => true,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'sanitize_html' => true,
])
;
}
+16 -8
View File
@@ -8,6 +8,8 @@ use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RoomSelectType extends AbstractType
@@ -20,15 +22,8 @@ class RoomSelectType extends AbstractType
$data = $event->getData();
$form = $event->getForm();
// Build label with pricing
$label = 'Anzahl '.$data->roomLabel;
if (null !== $data->roomPrice) {
$formattedPrice = number_format((float) $data->roomPrice, 2, ',', '.');
$label .= sprintf(' (€%s pro Person)', $formattedPrice);
}
$form->add('quantity', StepSelectChoiceType::class, [
'label' => $label,
'label' => false,
'required' => true,
'min_value' => 0,
'max_value' => $data->maxQuantity,
@@ -37,6 +32,19 @@ class RoomSelectType extends AbstractType
});
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$data = $form->getData();
$view->vars['label_room'] = $data->roomLabel;
$view->vars['label_price'] = null;
if (null !== $data->roomPrice) {
$formattedPrice = number_format((float) $data->roomPrice, 2, ',', '.');
$view->vars['label_price'] = sprintf(' %s € pro Person', $formattedPrice);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
@@ -167,7 +167,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_label' => fn (?Service $service) => $service?->label,
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) {
return [];
@@ -205,7 +205,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_label' => fn (?Service $service) => $service?->label,
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) {
return [];
@@ -251,7 +251,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_label' => fn (?Service $service) => $service?->label,
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) {
return [];
@@ -294,7 +294,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_label' => fn (?Service $service) => $service?->label,
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) {
return [];
@@ -353,7 +353,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_label' => fn (?Service $service) => $service?->label,
'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) {
if (null === $service) {
return [];
@@ -396,7 +396,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$bookingDto,
$participantIndex
),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
'choice_label' => fn (Service $service) => $service?->label,
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
@@ -427,7 +427,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rückfahrt',
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
'choice_label' => fn (Service $service) => $service?->label,
'choice_value' => 'id',
'expanded' => true,
'multiple' => false,
@@ -653,41 +653,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.'));
}
/**
* Format transportation service labels with type indicator and pricing.
*
* Creates user-friendly labels for transportation services that include:
* - Transportation type icon (🚌 for bus, 🚗 for car)
* - Service name
* - Pricing (with discount indication for negative prices)
* - Availability warning for limited services
*
* @param Service $service The transportation service to format
*
* @return string The formatted transportation service label
*/
private function formatTransportationServiceLabel(Service $service): string
{
$label = $service->label;
if (null === $service->price || 0.0 === $service->price) {
return $service->label;
}
if ($service->price > 0) {
$label .= sprintf(' (€%s)', number_format($service->price, 2, ',', '.'));
} else {
$label .= sprintf(' (-%s€ Rabatt)', number_format(abs($service->price), 2, ',', '.'));
}
// Add availability warning if limited
if (null !== $service->available && $service->available <= 5) {
$label .= sprintf(' (nur %d verfügbar)', $service->available);
}
return $label;
}
private function formatPickupLabelWithPrice(?Pickup $pickup): string
{
if (null === $pickup) {
@@ -935,11 +900,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/
private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array
{
// Only apply filtering in create mode
if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) {
return $services;
}
// Separate PKW/CAR from other services (BUS, etc.)
$pkwServices = [];
$otherServices = [];
-49
View File
@@ -1,49 +0,0 @@
<?php
namespace App\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
class MenuBuilder
{
public function __construct(
private readonly FactoryInterface $factory,
) {
}
public function createMainMenu(): ItemInterface
{
$menu = $this->factory->createItem('root', [
'childrenAttributes' => [
'class' => 'menu menu--main',
],
]);
$menu->addChild('Meine Daten', [
'route' => 'app_personal_data',
'linkAttributes' => [
'data-action' => 'loading#toggle',
],
]);
$menu->addChild('Meine Buchungen', [
'route' => 'app_bookings',
'linkAttributes' => [
'data-action' => 'loading#toggle',
],
'extras' => [
'routes' => [
'app_booking_edit',
],
],
]);
$menu->addChild('Logout', [
'route' => 'app_logout',
'linkAttributes' => [
'data-action' => 'loading#toggle',
],
]);
return $menu;
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('app_personal_data'));
return new RedirectResponse($this->urlGenerator->generate('app_account'));
}
private function collectRoles(CrmAttributes $crmAttributes): array
+30 -4
View File
@@ -69,10 +69,14 @@ class BookingSummaryDataService
// Calculate participant count from room capacity (source of truth)
$participantCount = $this->calculateParticipantCountFromRooms($bookingDto);
// Calculate payable amount after voucher deductions
$payableAmount = $this->calculatePayableAmount($bookingDto, $pricingData['grandTotal'], $participantPrices);
return new BookingSummaryDto(
selectedRooms: $selectedRooms,
participantCount: $participantCount,
totalPrice: number_format($totalPrice, 2, ',', '.').' €',
totalPrice: $pricingData['grandTotal'],
payableAmount: $payableAmount,
groupedSelectedRooms: $groupedSelectedRooms,
assignmentCounts: $roomCounts,
pricingData: $pricingData,
@@ -81,13 +85,35 @@ class BookingSummaryDataService
}
/**
* Calculates participant count from room selections.
* Calculates the payable amount after voucher deductions.
*
* This is the source of truth for participant count, calculated by
* multiplying each selected room's quantity by its maximum capacity (maxPax).
* @param array<int, float> $participantPrices Prices per participant for percentage voucher calculation
*/
private function calculatePayableAmount(BookingDto $bookingDto, float $grandTotal, array $participantPrices): float
{
$acceptedVouchers = $bookingDto->getAcceptedVouchers($participantPrices);
if (null === $acceptedVouchers) {
return $grandTotal;
}
return max(0.0, $grandTotal - $acceptedVouchers->getTotalDiscount());
}
/**
* Calculates participant count.
*
* In edit mode, counts actual participants. In create mode, calculates
* from room selections by multiplying quantity by maximum capacity (maxPax).
*/
private function calculateParticipantCountFromRooms(BookingDto $bookingDto): int
{
// In edit mode, use actual participant count
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
return count($bookingDto->participants);
}
// In create mode, calculate from room selections
$totalCapacity = 0;
$availableRooms = $bookingDto->travel->getAvailableRooms();
+1 -2
View File
@@ -24,12 +24,11 @@ class AppExtension extends AbstractExtension
public function getFunctions(): array
{
return [
new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]),
new TwigFunction('is_participant_eligible', [AppRuntime::class, 'isParticipantEligible']),
new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]),
new TwigFunction('is_static_text', [AppRuntime::class, 'isStaticText']),
new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']),
new TwigFunction('gravatar_url', [AppRuntime::class, 'getGravatarUrl']),
new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']),
];
}
}
+24 -15
View File
@@ -52,14 +52,6 @@ class AppRuntime implements RuntimeExtensionInterface
return $this->intlExtension->formatCurrency($amount, 'EUR');
}
public function renderIcon(Environment $environment, string $icon, string $classes = 'w-5 h-5'): string
{
return $environment->render('_partials/_icon.html.twig', [
'icon' => $icon,
'class' => $classes,
]);
}
public function mapStatus(string $status): string
{
$status = strtoupper($status);
@@ -171,18 +163,35 @@ class AppRuntime implements RuntimeExtensionInterface
}
/**
* Generates a Gravatar URL for the given email address.
* Recursively collects labels of invalid form fields.
*
* @param string $email The email address
* @param int $size The size of the avatar in pixels (default: 80)
* @param FormView $form The form view to check
*
* @return string The Gravatar URL
* @return array<string> Array of invalid field labels
*/
public function getGravatarUrl(string $email, int $size = 80): string
public function collectInvalidFieldLabels(FormView $form): array
{
$hash = md5(strtolower(trim($email)));
$labels = [];
return sprintf('https://www.gravatar.com/avatar/%s?s=%d&d=404', $hash, $size);
foreach ($form->children as $child) {
$hasErrors = \count($child->vars['errors']) > 0;
$hasInvalidChildren = false === $child->vars['valid'];
if ($hasErrors || $hasInvalidChildren) {
$hasChildren = \count($child->children) > 0;
$isExpanded = $child->vars['expanded'] ?? false;
if ($hasChildren && false === $isExpanded && false === $hasErrors) {
// Nested form without own errors (e.g., address, bodyDimensions) - recurse
$labels = array_merge($labels, $this->collectInvalidFieldLabels($child));
} else {
// Leaf field, expanded choice, or compound field with own errors
$labels[] = $child->vars['label'] ?? $child->vars['name'];
}
}
}
return $labels;
}
/**