intlExtension->formatCurrency($amount, 'EUR'); } /** * 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, "inkl." for zero/null prices (if enabled), or empty string */ public function formatServicePrice(float|int|null $price, bool $showIncludedLabel = true): string { if (null === $price || 0 === $price || 0.0 === $price) { return $showIncludedLabel ? 'inkl.' : ''; } return $this->intlExtension->formatCurrency((float) $price, 'EUR'); } public function mapStatus(string $status): string { $status = strtoupper($status); return match ($status) { 'F', 'F/S' => 'Festbuchung', 'S' => 'Stornierung', 'O' => 'Option', 'U' => 'Umbuchung', 'A' => 'Anfrage', default => '', }; } public function mapGender(?string $gender): string { if (null === $gender) { return ''; } $gender = strtoupper($gender); return match ($gender) { 'M' => 'männlich', 'W' => 'weiblich', 'D' => 'divers', default => '', }; } public function mapCountry(?string $country): ?string { if (null === $country) { return null; } return $this->countryDataProvider->get($country)?->name; } public function mapNationality(?string $nationality): ?string { if (null === $nationality) { return null; } return $this->countryDataProvider->get($nationality)?->nationality; } /** * The labels of the roles an account actually holds — no ROLE_USER, no nominations. * * @param string[] $roles * * @return string[] */ public function effectiveRoles(array $roles): array { return $this->roleLabels(Role::effectiveOnly($roles)); } /** * The labels of the roles the BusPro CRM claims for an account but nobody has approved. * * @param string[] $roles * * @return string[] */ public function nominatedRoles(array $roles): array { return array_values(Role::nominatedFrom($roles)); } /** * A role without a label is passed through unchanged so it stays visible instead of * silently disappearing. * * @param string[] $roles * * @return string[] */ private function roleLabels(array $roles): array { $labels = Role::labels(); $mapped = []; foreach ($roles as $role) { $mapped[] = $labels[$role] ?? $role; } return $mapped; } public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); } /** * Renders a QA attribute for testing purposes. * * @param string $label The label for the QA attribute * @param string|null $value The value for the QA attribute * * @return string The rendered QA attribute */ public function renderQaAttribute(string $label, ?string $value = null): string { if ('prod' === $this->environment) { return ''; } if (null === $value) { return sprintf(' data-qa-%s', strtolower($label)); } return sprintf(' data-qa-%s="%s"', strtolower($label), $value); } public function renderIcon(Environment $environment, string $icon, string $classes = 'w-5 h-5'): string { return $environment->render('_partials/_icon.html.twig', [ 'icon' => $icon, 'class' => $classes, ]); } /** * Checks if a form field should be rendered as static text. * * Accepts either a FormView (when field is in form structure) or a string field name * (when field is excluded from form but needs to be checked). * * @param FormView|string $field The FormView or field name * @param BookingDto $bookingDto The booking context (create or edit mode) * @param int $participantIndex The participant index * * @return bool True if field should render as static text */ public function isStaticText(FormView|string $field, BookingDto $bookingDto, int $participantIndex): bool { $fieldName = $field instanceof FormView ? $field->vars['name'] : $field; $provider = $this->getProvider($bookingDto); return $provider->shouldRenderAsStaticText($fieldName, $bookingDto, $participantIndex); } /** * Checks if a form field should be completely hidden (not rendered at all). * * Accepts either a FormView (when field is in form structure) or a string field name * (when field is excluded from form but needs to be checked). * * @param FormView|string $field The FormView or field name * @param BookingDto $bookingDto The booking context (create or edit mode) * @param int $participantIndex The participant index * * @return bool True if field should be hidden (not rendered) */ public function isHidden(FormView|string $field, BookingDto $bookingDto, int $participantIndex): bool { $fieldName = $field instanceof FormView ? $field->vars['name'] : $field; $provider = $this->getProvider($bookingDto); return !$provider->shouldIncludeField($fieldName, $bookingDto, $participantIndex); } /** * Recursively collects labels of invalid form fields. * * @param FormView $form The form view to check * * @return array Array of invalid field labels */ public function collectInvalidFieldLabels(FormView $form): array { $labels = []; foreach ($form->children as $child) { // Buttons (e.g. submit buttons) aren't validatable fields and have no errors/valid vars. if (!\array_key_exists('errors', $child->vars)) { continue; } $hasErrors = \count($child->vars['errors']) > 0; $hasInvalidChildren = false === $child->vars['valid']; if ($hasErrors || $hasInvalidChildren) { $hasChildren = \count($child->children) > 0; $isExpanded = $child->vars['expanded'] ?? false; if ($hasChildren && false === $isExpanded && false === $hasErrors) { // Nested form without own errors (e.g., address, bodyDimensions) - recurse $labels = array_merge($labels, $this->collectInvalidFieldLabels($child)); } else { // Leaf field, expanded choice, or compound field with own errors $label = $child->vars['label'] ?? null; $labels[] = (\is_string($label) && '' !== $label) ? $label : $child->vars['name']; } } } return $labels; } /** * Checks whether a travel start cutoff window has been reached. * * Uses inclusive day semantics: cutoff is reached when current day is on or * after (travel start day - $daysBeforeStart). */ public function isTravelStartCutoffReached(BookingDto $bookingDto, int $daysBeforeStart = 4): bool { try { $condition = new TravelStartCutoffReachedCondition($daysBeforeStart); } catch (\InvalidArgumentException) { return false; } return $condition->evaluate($bookingDto, 0, []); } /** * Returns the current booking theme from request attribute. * * The theme is resolved by DomainThemeListener based on the request host. * Defaults to 'base' if no theme is set. */ public function getBookingTheme(): string { return $this->getDomainConfig()->theme; } /** * Returns the GTM container ID for the current domain. * * Returns an empty string if no GTM ID is configured for this domain. */ public function getGtmId(): string { return $this->getDomainConfig()->gtmId; } /** * Returns the CMP (Consent Management Platform) URL for the current domain. * * Returns an empty string if no CMP URL is configured for this domain. */ public function getCmpUrl(): string { return $this->getDomainConfig()->cmpUrl; } public function getEncodedReturnUrl(): string { $masterRequest = $this->requestStack->getMainRequest(); if (null === $masterRequest) { return ''; } return rawurlencode($masterRequest->getRequestUri()); } /** * The current query string with the given keys removed — the target of a filter chip's * dismiss link. * * The pagination is reset because the shorter result set makes the current page meaningless, * and the filter marker is kept because dismissing a chip is still an explicit filter action: * dropping it would snap the list back to its defaults instead of widening it. * * @param string[] $keys * * @return array */ public function getFilterQueryWithout(array $keys): array { $request = $this->requestStack->getCurrentRequest(); if (null === $request) { return []; } $query = $request->query->all(); foreach ([...$keys, 'page'] as $key) { unset($query[$key]); } $query[AbstractListFilterDto::MARKER] = '1'; return $query; } private function getDomainConfig(): DomainConfig { $request = $this->requestStack->getCurrentRequest(); if (null === $request) { return DomainConfig::default(); } return $request->attributes->get( DomainThemeListener::DOMAIN_CONFIG_ATTRIBUTE, DomainConfig::default() ); } /** * Gets the appropriate field state provider based on booking mode. * * @param BookingDto $bookingDto The booking context * * @return CreateFieldStateProvider|EditFieldStateProvider The appropriate provider */ private function getProvider(BookingDto $bookingDto): CreateFieldStateProvider|EditFieldStateProvider { return BookingDto::MODE_CREATE === $bookingDto->getMode() ? $this->createFieldStateProvider : $this->editFieldStateProvider; } }