403 lines
13 KiB
PHP
403 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Twig;
|
|
|
|
use App\BusProNet\DataProvider\CountryDataProvider;
|
|
use App\EventListener\DomainThemeListener;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\Filter\AbstractListFilterDto;
|
|
use App\Form\Service\Condition\TravelStartCutoffReachedCondition;
|
|
use App\Form\Service\CreateFieldStateProvider;
|
|
use App\Form\Service\EditFieldStateProvider;
|
|
use App\Model\DomainConfig;
|
|
use App\Security\Role;
|
|
use App\Service\ParticipantEligibilityChecker;
|
|
use Symfony\Component\Form\FormView;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Twig\Environment;
|
|
use Twig\Extension\RuntimeExtensionInterface;
|
|
use Twig\Extra\Intl\IntlExtension;
|
|
|
|
/**
|
|
* Runtime implementation for AppExtension's filters and functions.
|
|
*
|
|
* Provides formatting utilities (money, file size, service prices), code-to-label
|
|
* mapping for display (gender, status, country), and field state introspection
|
|
* for conditional rendering in templates.
|
|
*/
|
|
class AppRuntime implements RuntimeExtensionInterface
|
|
{
|
|
public function __construct(
|
|
private readonly IntlExtension $intlExtension,
|
|
private readonly CountryDataProvider $countryDataProvider,
|
|
private readonly ParticipantEligibilityChecker $participantEligibilityService,
|
|
private readonly CreateFieldStateProvider $createFieldStateProvider,
|
|
private readonly EditFieldStateProvider $editFieldStateProvider,
|
|
private readonly RequestStack $requestStack,
|
|
private readonly string $environment,
|
|
) {
|
|
}
|
|
|
|
public function formatBytes(int $bytes, ?int $precision = 2): string
|
|
{
|
|
if (0 === $bytes) {
|
|
return '0 B';
|
|
}
|
|
|
|
$units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
$factor = (int) floor(log($bytes, 1024));
|
|
|
|
return sprintf("%.{$precision}f %s", $bytes / (1024 ** $factor), $units[$factor]);
|
|
}
|
|
|
|
public function formatMoney(int $amount): string
|
|
{
|
|
// Amounts are stored as integers so divide by 100 first
|
|
$amount = $amount / 100;
|
|
|
|
return $this->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<string> 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<string, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
}
|