fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [ * 'label' => 'New Field Label', * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), * ]; * * Provider Pattern Benefits: * - Lazy evaluation (options only generated when needed) * - Context-aware configuration * - Easy to test individual field logic * - Supports complex interdependencies */ protected function registerFieldOptionProviders(): void { // Room assignment field provider (only available for create workflow) $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zimmer', 'placeholder' => 'Bitte wählen', // Use factory to create context-aware choice loader that: // - Shows only available rooms for this participant // - Excludes rooms already assigned to other participants // - Respects room capacity and booking constraints 'choice_loader' => $bookingDto instanceof BookingCreateDto ? $this->roomChoiceLoaderFactory->create( $bookingDto->participants, $bookingDto->getSelectedRooms(), $participantIndex ) : null, ]; // Courses field provider - provides age-appropriate courses from travel data $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; } $attributes = []; // Pre-select and make readonly for mandatory services if (true === $service->mandatory) { $attributes['checked'] = true; $attributes['readonly'] = true; $attributes['class'] = 'text-pink'; $attributes['title'] = 'Diese Leistung ist nicht abwählbar'; } return $attributes; }, ]; // Board field provider - provides age-appropriate board options from travel data $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Rentals field provider - provides age-appropriate rental options from travel data filtered by date range $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, 'required' => true, 'choices' => $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Wünsche oder Anmerkungen zum Zimmer', 'required' => false, 'clean_xss' => true, 'attr' => [ 'rows' => 2, ], ]; // Transportation field providers - handles outbound/inbound transportation and pickup selection // Outbound Transportation $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Hinfahrt', 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'attr' => [ 'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-target' => '#booking-summary', 'hx-trigger' => 'change', ], ]; // Inbound Transportation $this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Rückfahrt', 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, 'attr' => [ 'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-target' => '#booking-summary', 'hx-trigger' => 'change', ], ]; // Outbound Pickup (conditional - only shown when outbound transportation is bus) $this->fieldOptionProviders['pickupOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zustieg Hinfahrt', 'choices' => $bookingDto->travel->pickupsTo, 'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup), 'choice_value' => 'id', 'expanded' => false, // Dropdown for pickups 'multiple' => false, 'required' => true, 'placeholder' => 'Zustieg auswählen', ]; // Inbound Pickup (conditional - only shown when inbound transportation is bus) $this->fieldOptionProviders['pickupInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Ausstieg Rückfahrt', 'choices' => $bookingDto->travel->pickupsFro, 'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup), 'choice_value' => 'id', 'expanded' => false, 'multiple' => false, 'required' => true, 'placeholder' => 'Ausstieg auswählen', ]; // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type $this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)), 'required' => false, ]; // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ // 'label' => 'Meal Preference', // 'choices' => [ // 'Standard' => 'standard', // 'Vegetarian' => 'vegetarian', // 'Vegan' => 'vegan', // ], // ]; } /** * Formats service label with pricing information. * * @param Service|null $service The service to format * * @return string The formatted label */ private function formatServiceLabelWithPrice(?Service $service): string { if (null === $service) { return ''; } if (null === $service->price || 0.0 === $service->price) { return $service->label; } if ($service->price < 0) { // Negative prices are discounts return sprintf('%s (-%s€ Rabatt)', $service->label, number_format(abs($service->price), 2, ',', '.')); } return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); } /** * Format transportation service labels with type indicator and pricing. * * Creates user-friendly labels for transportation services that include: * - Transportation type icon (🚌 for bus, 🚗 for car) * - Service name * - Pricing (with discount indication for negative prices) * - Availability warning for limited services * * @param Service $service The transportation service to format * * @return string The formatted transportation service label */ private function formatTransportationServiceLabel(Service $service): string { $label = $service->label; // Transportation type indicators removed for cleaner labels // Add pricing with discount indication if (null !== $service->price) { if ($service->price > 0) { $label .= sprintf(' (+€%.2f)', $service->price); } elseif ($service->price < 0) { $label .= sprintf(' (-€%.2f Discount)', abs($service->price)); } } // Add availability warning if limited if (null !== $service->available && $service->available <= 5) { $label .= sprintf(' (nur %d verfügbar)', $service->available); } return $label; } /** * Format pickup labels with city and street information. * * Creates user-friendly labels for pickup locations following the existing pattern: * - Primary format: "City (Street)" if street is available * - Fallback format: "City" if no street information * * @param Pickup $pickup The pickup location to format * * @return string The formatted pickup location label */ private function formatPickupLabel(Pickup $pickup): string { $label = $pickup->city ?? ''; if (null !== $pickup->street && '' !== trim($pickup->street)) { $label .= ' ('.$pickup->street.')'; } return $label; } private function formatPickupLabelWithPrice(?Pickup $pickup): string { if (null === $pickup) { return ''; } $label = $this->formatPickupLabel($pickup); if (null === $pickup->price || 0.0 === $pickup->price) { return $label; } if ($pickup->price < 0) { // Negative prices are discounts return sprintf('%s (-%s€ Rabatt)', $label, number_format(abs($pickup->price), 2, ',', '.')); } return sprintf('%s (€%s)', $label, number_format($pickup->price, 2, ',', '.')); } /** * Gets the parking checkbox label with pricing information. * * Creates a checkbox label for the single parking service including pricing. * Since there's only ever one parking type, we take the first available service. * * @param array $parkingServices Array of available parking services * * @return string The formatted checkbox label with pricing */ private function getParkingCheckboxLabel(array $parkingServices): string { if (empty($parkingServices)) { return 'Parkplatz'; } $parkingService = reset($parkingServices); // Get the first (and only) parking service return $this->formatServiceLabelWithPrice($parkingService); } /** * Filters services based on participant's age constraints. * * Removes services that have age restrictions the participant doesn't meet. * If no age evaluator is configured or participant has no birth date, * returns empty array to be handled by field visibility conditions. * * @param array $services Services to filter * @param BookingDtoInterface $bookingDto Booking data containing participant info * @param int $participantIndex Index of participant to evaluate * * @return array Filtered services array */ private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array { $ageEvaluator = new ServiceAgeEvaluator(); $participant = $bookingDto->getParticipant($participantIndex); // If no birthdate provided, return empty array (handled by field visibility conditions) if (null === $participant || null === $participant->dateOfBirth) { return []; } return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator) { // No age constraints = available to all if (false === $ageEvaluator->canEvaluate($service)) { return true; } return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } }