From 1fd9f8d1f8cf73f10f6f2fcfb46c56c30a057025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 12 Dec 2025 08:47:25 +0100 Subject: [PATCH] feat: always show all available ski passes but readonly if age doesn't match --- .../ParticipantFieldOptionsProvider.php | 145 +++++++++++++++++- src/Twig/AppRuntime.php | 9 +- templates/booking/_form_theme.html.twig | 15 +- templates/booking/_participant_form.html.twig | 21 ++- ...articipantFieldOptionsProviderBabyTest.php | 5 +- translations/messages.de.yaml | 14 ++ 6 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 translations/messages.de.yaml diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 6a2407b..9c6b3cf 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -16,6 +16,7 @@ use App\Form\Service\Abstract\AbstractFieldOptionsProvider; use App\Service\BookingPriceCalculatorService; use App\Service\InsuranceService; use App\Service\ServiceAvailabilityCalculator; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Provides dynamic field options for participant form fields. @@ -40,6 +41,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, private readonly InsuranceService $insuranceService, private readonly BookingPriceCalculatorService $priceCalculatorService, + private readonly TranslatorInterface $translator, ) { parent::__construct(); } @@ -278,7 +280,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => false, 'expanded' => true, 'required' => true, - 'choices' => $this->filterServicesByAgeConstraints( + 'choices' => $this->filterSkiPassChoices( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true), $bookingDto, $participantIndex @@ -297,6 +299,16 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } + // Check age restriction first (takes precedence over availability) + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service) + && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + + return $attributes; + } + // Make readonly if service is unavailable (intelligently handles edit mode) if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) { $attributes['readonly'] = true; @@ -764,6 +776,137 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider }); } + /** + * Filters ski pass choices for display. + * + * Unlike other services, ski passes are shown even when age-restricted + * (marked as readonly instead of hidden) to avoid user confusion about + * included ski passes not being visible. + * + * Baby filtering is preserved: babies only see services explicitly + * including their age range. + * + * @param Service[] $services Array of ski pass Service objects to filter + * @param BookingDto $bookingDto The booking DTO containing participant data + * @param int $participantIndex Index of the participant to evaluate + * + * @return Service[] Filtered array of ski pass services + */ + private function filterSkiPassChoices(array $services, BookingDto $bookingDto, int $participantIndex): array + { + $participant = $bookingDto->getParticipant($participantIndex); + + // If no birthdate provided, return empty array (handled by field visibility conditions) + if (null === $participant || null === $participant->dateOfBirth) { + return []; + } + + // Check if participant is a baby + $age = $participant->getAge($bookingDto->travel->dateFrom); + $isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE; + + // For babies: keep existing filtering logic (only show services with explicit baby age ranges) + if ($isBaby) { + return $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex); + } + + // For non-babies: return all services (age-restricted ones will be marked readonly in choice_attr) + return $services; + } + + /** + * Generates a German tooltip explaining age restrictions for a service. + * + * @param Service $service The service with age restrictions + * + * @return string German tooltip text explaining the restriction + */ + private function getAgeRestrictionTooltip(Service $service): string + { + $constraint = $this->translateAgeConstraint($service); + + return $this->translator->trans('service.age_constraint.prefix', ['%constraint%' => $constraint]); + } + + /** + * Translates a service's age constraints to a localized description. + * + * @param Service $service The service with age constraints + * + * @return string Translated constraint description + */ + private function translateAgeConstraint(Service $service): string + { + return match ($service->ageConstraintType) { + 'absolute_age' => $this->translateAbsoluteAgeConstraint($service), + 'birth_year' => $this->translateBirthYearConstraint($service), + 'mixed' => $this->translateAbsoluteAgeConstraint($service) + .$this->translator->trans('service.age_constraint.mixed.separator') + .$this->translateBirthYearConstraint($service), + default => '', + }; + } + + /** + * Translates absolute age constraints (ageFrom/ageTo). + */ + private function translateAbsoluteAgeConstraint(Service $service): string + { + if (null !== $service->ageFrom && null !== $service->ageTo) { + return $this->translator->trans('service.age_constraint.absolute_age.range', [ + '%ageFrom%' => $service->ageFrom, + '%ageTo%' => $service->ageTo, + ]); + } + + if (null !== $service->ageFrom) { + return $this->translator->trans('service.age_constraint.absolute_age.min', [ + '%ageFrom%' => $service->ageFrom, + ]); + } + + if (null !== $service->ageTo) { + return $this->translator->trans('service.age_constraint.absolute_age.max', [ + '%ageTo%' => $service->ageTo, + ]); + } + + return ''; + } + + /** + * Translates birth year constraints (birthYearFrom/birthYearTo). + */ + private function translateBirthYearConstraint(Service $service): string + { + if (null !== $service->birthYearFrom && null !== $service->birthYearTo) { + if ($service->birthYearFrom === $service->birthYearTo) { + return $this->translator->trans('service.age_constraint.birth_year.single', [ + '%year%' => $service->birthYearFrom, + ]); + } + + return $this->translator->trans('service.age_constraint.birth_year.range', [ + '%yearFrom%' => $service->birthYearFrom, + '%yearTo%' => $service->birthYearTo, + ]); + } + + if (null !== $service->birthYearFrom) { + return $this->translator->trans('service.age_constraint.birth_year.min', [ + '%yearFrom%' => $service->birthYearFrom, + ]); + } + + if (null !== $service->birthYearTo) { + return $this->translator->trans('service.age_constraint.birth_year.max', [ + '%yearTo%' => $service->birthYearTo, + ]); + } + + return ''; + } + /** * Generates label for rental insurance checkbox including pricing information. */ diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index bf33568..01cec6f 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -55,14 +55,15 @@ class AppRuntime implements RuntimeExtensionInterface /** * Formats a service price, showing "inkl." for zero-priced (included) services. * - * @param float|int|null $price The price to format + * @param float|int|null $price The price to format + * @param bool $showIncludedLabel Whether to show "inkl." for zero prices (default: true) * - * @return string The formatted price or "inkl." for zero/null prices + * @return string The formatted price, "inkl." for zero/null prices (if enabled), or empty string */ - public function formatServicePrice(float|int|null $price): string + public function formatServicePrice(float|int|null $price, bool $showIncludedLabel = true): string { if (null === $price || 0 === $price || 0.0 === $price) { - return 'inkl.'; + return $showIncludedLabel ? 'inkl.' : ''; } return $this->intlExtension->formatCurrency((float) $price, 'EUR'); diff --git a/templates/booking/_form_theme.html.twig b/templates/booking/_form_theme.html.twig index 2509064..0f16284 100644 --- a/templates/booking/_form_theme.html.twig +++ b/templates/booking/_form_theme.html.twig @@ -68,17 +68,22 @@ {% if choiceData %} {% if choiceData.price is defined %} - {{ choiceData.price | format_service_price }} + {% set isPkw = choiceData.subType is defined and choiceData.subType in ['PKW', 'CAR'] %} + {{ choiceData.price | format_service_price(not isPkw) }} {% endif %} {% endif %} {% set childTooltip = child.vars.attr['data-tooltip']|default(null) %} -
-
- {{- form_widget(child, { 'attr': child_attr }) -}} + {% if isReadonly or childTooltip is not null %} + + + {{- form_widget(child, { 'attr': child_attr }) -}} +
- + {% else %} + {{- form_widget(child, { 'attr': child_attr }) -}} + {% endif %} {% endfor -%} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index fac3be8..b430779 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -97,8 +97,11 @@ {# Macro to render insurance choice table row with product info links #} -{% macro insurance_choice_row(child, price, urlsProductInfo, widgetAttr = {}) %} +{% macro insurance_choice_row(child, choiceData, widgetAttr = {}) %} {% set isReadonly = child.vars.attr.readonly is defined %} + {% set isNoInsurance = choiceData.noInsurance|default(false) %} + {% set price = choiceData.price|default(null) %} + {% set urlsProductInfo = choiceData ? choiceData.getAllUrlsProductInfo() : [] %}
{{ child.vars.label }}
@@ -120,15 +123,19 @@ {%- endif -%} - {{ price | format_service_price }} + {{ price | format_service_price(not isNoInsurance) }} {% set childTooltip = child.vars.attr['data-tooltip']|default(null) %} -
-
- {{ form_widget(child, { 'attr': widgetAttr }) }} + {% if isReadonly or childTooltip is not null %} + + + {{ form_widget(child, { 'attr': widgetAttr }) }} +
- + {% else %} + {{ form_widget(child, { 'attr': widgetAttr }) }} + {% endif %} {% endmacro %} @@ -496,7 +503,7 @@ {% endif %} {% for child in form.insurance %} {% set choiceData = form.insurance.vars.choices[loop.index0].data %} - {{ macros.insurance_choice_row(child, choiceData.price|default(null), choiceData ? choiceData.getAllUrlsProductInfo() : [], htmxAttr) }} + {{ macros.insurance_choice_row(child, choiceData, htmxAttr) }} {% endfor %} {% else %} diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php index ab5eaf4..1a947f8 100644 --- a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php +++ b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php @@ -15,6 +15,7 @@ use App\Service\BookingPriceCalculatorService; use App\Service\InsuranceService; use App\Service\ServiceAvailabilityCalculator; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class ParticipantFieldOptionsProviderBabyTest extends TestCase { @@ -26,11 +27,13 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase $this->serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class); $insuranceService = $this->createMock(InsuranceService::class); $priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class); + $translator = $this->createMock(TranslatorInterface::class); $this->provider = new ParticipantFieldOptionsProvider( $this->serviceAvailabilityCalculator, $insuranceService, - $priceCalculatorService + $priceCalculatorService, + $translator ); } diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml new file mode 100644 index 0000000..3c0e4ba --- /dev/null +++ b/translations/messages.de.yaml @@ -0,0 +1,14 @@ +service: + age_constraint: + prefix: 'Nur für %constraint%' + absolute_age: + range: 'Alter %ageFrom%-%ageTo% Jahre' + min: 'Alter %ageFrom%+ Jahre' + max: 'Alter bis %ageTo% Jahre' + birth_year: + single: 'Jahrgang %year%' + range: 'Jahrgang %yearFrom%-%yearTo%' + min: 'Jahrgang %yearFrom% oder später' + max: 'Jahrgang bis %yearTo%' + mixed: + separator: ' und '