feat: always show all available ski passes but readonly if age doesn't match
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -56,13 +56,14 @@ 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 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');
|
||||
|
||||
@@ -68,17 +68,22 @@
|
||||
{% if choiceData %}
|
||||
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
|
||||
{% 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 %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
{% 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 %}>
|
||||
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
|
||||
{% if isReadonly or childTooltip is not null %}
|
||||
<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 }) -}}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{- form_widget(child, { 'attr': child_attr }) -}}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor -%}
|
||||
|
||||
@@ -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() : [] %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<div>{{ child.vars.label }}</div>
|
||||
@@ -120,15 +123,19 @@
|
||||
{%- endif -%}
|
||||
</td>
|
||||
<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 class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
{% 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 %}>
|
||||
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
|
||||
{% if isReadonly or childTooltip is not null %}
|
||||
<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 }) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form_widget(child, { 'attr': widgetAttr }) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
</table>
|
||||
{% else %}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '
|
||||
Reference in New Issue
Block a user