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\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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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