feat: always show all available ski passes but readonly if age doesn't match

This commit is contained in:
Björn Fromme
2025-12-12 08:47:25 +01:00
parent c3519124b7
commit 1d97a19982
6 changed files with 191 additions and 18 deletions
@@ -16,6 +16,7 @@ use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
use App\Service\BookingPriceCalculatorService; use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService; use App\Service\InsuranceService;
use App\Service\ServiceAvailabilityCalculator; use App\Service\ServiceAvailabilityCalculator;
use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* Provides dynamic field options for participant form fields. * Provides dynamic field options for participant form fields.
@@ -40,6 +41,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
private readonly InsuranceService $insuranceService, private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService, private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly TranslatorInterface $translator,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -278,7 +280,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'multiple' => false, 'multiple' => false,
'expanded' => true, 'expanded' => true,
'required' => true, 'required' => true,
'choices' => $this->filterServicesByAgeConstraints( 'choices' => $this->filterSkiPassChoices(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true), $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true),
$bookingDto, $bookingDto,
$participantIndex $participantIndex
@@ -297,6 +299,16 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $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) // Make readonly if service is unavailable (intelligently handles edit mode)
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) {
$attributes['readonly'] = true; $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. * Generates label for rental insurance checkbox including pricing information.
*/ */
+4 -3
View File
@@ -56,13 +56,14 @@ class AppRuntime implements RuntimeExtensionInterface
* Formats a service price, showing "inkl." for zero-priced (included) services. * 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) { if (null === $price || 0 === $price || 0.0 === $price) {
return 'inkl.'; return $showIncludedLabel ? 'inkl.' : '';
} }
return $this->intlExtension->formatCurrency((float) $price, 'EUR'); return $this->intlExtension->formatCurrency((float) $price, 'EUR');
+8 -3
View File
@@ -68,17 +68,22 @@
{% if choiceData %} {% if choiceData %}
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap"> <td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if choiceData.price is defined %} {% 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 %}
</td> </td>
{% endif %} {% endif %}
<td class="border border-primary-bg p-2 align-top w-12 text-center"> <td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %} {% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}> {% if isReadonly or childTooltip is not null %}
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}"> <div{% if isReadonly %} class="cursor-not-allowed"{% endif %}{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div{% if isReadonly %} class="pointer-events-none"{% endif %}>
{{- form_widget(child, { 'attr': child_attr }) -}} {{- form_widget(child, { 'attr': child_attr }) -}}
</div> </div>
</div> </div>
{% else %}
{{- form_widget(child, { 'attr': child_attr }) -}}
{% endif %}
</td> </td>
</tr> </tr>
{% endfor -%} {% endfor -%}
+12 -5
View File
@@ -97,8 +97,11 @@
{# Macro to render insurance choice table row with product info links #} {# 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 isReadonly = child.vars.attr.readonly is defined %}
{% set isNoInsurance = choiceData.noInsurance|default(false) %}
{% set price = choiceData.price|default(null) %}
{% set urlsProductInfo = choiceData ? choiceData.getAllUrlsProductInfo() : [] %}
<tr> <tr>
<td class="border border-primary-bg p-2 align-top"> <td class="border border-primary-bg p-2 align-top">
<div>{{ child.vars.label }}</div> <div>{{ child.vars.label }}</div>
@@ -120,15 +123,19 @@
{%- endif -%} {%- endif -%}
</td> </td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap"> <td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{{ price | format_service_price }} {{ price | format_service_price(not isNoInsurance) }}
</td> </td>
<td class="border border-primary-bg p-2 align-top w-12 text-center"> <td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %} {% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}> {% if isReadonly or childTooltip is not null %}
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}"> <div{% if isReadonly %} class="cursor-not-allowed"{% endif %}{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div{% if isReadonly %} class="pointer-events-none"{% endif %}>
{{ form_widget(child, { 'attr': widgetAttr }) }} {{ form_widget(child, { 'attr': widgetAttr }) }}
</div> </div>
</div> </div>
{% else %}
{{ form_widget(child, { 'attr': widgetAttr }) }}
{% endif %}
</td> </td>
</tr> </tr>
{% endmacro %} {% endmacro %}
@@ -496,7 +503,7 @@
{% endif %} {% endif %}
{% for child in form.insurance %} {% for child in form.insurance %}
{% set choiceData = form.insurance.vars.choices[loop.index0].data %} {% 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 %} {% endfor %}
</table> </table>
{% else %} {% else %}
@@ -15,6 +15,7 @@ use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService; use App\Service\InsuranceService;
use App\Service\ServiceAvailabilityCalculator; use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class ParticipantFieldOptionsProviderBabyTest extends TestCase class ParticipantFieldOptionsProviderBabyTest extends TestCase
{ {
@@ -26,11 +27,13 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
$this->serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class); $this->serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
$insuranceService = $this->createMock(InsuranceService::class); $insuranceService = $this->createMock(InsuranceService::class);
$priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class); $priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class);
$translator = $this->createMock(TranslatorInterface::class);
$this->provider = new ParticipantFieldOptionsProvider( $this->provider = new ParticipantFieldOptionsProvider(
$this->serviceAvailabilityCalculator, $this->serviceAvailabilityCalculator,
$insuranceService, $insuranceService,
$priceCalculatorService $priceCalculatorService,
$translator
); );
} }
+14
View File
@@ -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 '