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 %}