From 762e8d6b7c335f1dbc6ce338cefdaf177ccde3c3 Mon Sep 17 00:00:00 2001 From: Azim Kordpour Date: Thu, 25 Jun 2026 16:28:03 +0200 Subject: [PATCH 1/7] MLZ-4892: Introduce travel information handling; add DTOs, actions, Livewire components, blades, localization, and Passolution integration for entry and health regulations confirmation. --- config/checkout.php | 5 + lang/de/page.php | 9 + lang/en/page.php | 9 + lang/es/page.php | 9 + lang/fr/page.php | 9 + .../LoadTravelInformationAction.php | 76 +++++ .../TravelInformationCombination.php | 24 ++ src/Enums/Section.php | 3 + .../TravelInformationResponseEntity.php | 17 ++ .../RegulatoryInformationResponse.php | 6 +- .../Connectors/PassolutionConnector.php | 41 +++ .../Responses/PassolutionContentResponse.php | 283 ++++++++++++++++++ .../Requests/GetContentRequest.php | 52 ++++ src/Livewire/BaseCheckoutComponent.php | 1 + src/Livewire/PaymentOptionsSection.php | 2 +- src/Livewire/PaymentPage.php | 16 + src/Livewire/TravelInformationSection.php | 120 ++++++++ src/Providers/CheckoutServiceProvider.php | 2 + src/Resources/Views/blades/index.blade.php | 7 + .../travel-information-section.blade.php | 94 ++++++ tests/Unit/Livewire/WorkflowSectionsTest.php | 100 ++++++- 21 files changed, 881 insertions(+), 4 deletions(-) create mode 100644 src/Actions/TravelInformation/LoadTravelInformationAction.php create mode 100644 src/Dtos/TravelInformation/TravelInformationCombination.php create mode 100644 src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php create mode 100644 src/Integrations/Passolution/Connectors/PassolutionConnector.php create mode 100644 src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php create mode 100644 src/Integrations/Passolution/Requests/GetContentRequest.php create mode 100644 src/Livewire/TravelInformationSection.php create mode 100644 src/Resources/Views/blades/travel-information-section.blade.php diff --git a/config/checkout.php b/config/checkout.php index 570c4d4f..134cfea8 100644 --- a/config/checkout.php +++ b/config/checkout.php @@ -67,6 +67,11 @@ 'active' => (bool) env('CHECKOUT_COMPUTOP_TOKEN_ACTIVE', false), 'name' => env('CHECKOUT_COMPUTOP_TOKEN_NAME', 'Computop - Token'), ], + 'passolution' => [ + 'active' => (bool) env('CHECKOUT_PASSOLUTION_ACTIVE', false), + 'base_url' => env('CHECKOUT_PASSOLUTION_BASE_URL', 'https://api.passolution.eu/api/v2'), + 'token' => env('CHECKOUT_PASSOLUTION_TOKEN', ''), + ], ], 'insurance' => [ diff --git a/lang/de/page.php b/lang/de/page.php index 5b424684..76216171 100644 --- a/lang/de/page.php +++ b/lang/de/page.php @@ -110,6 +110,15 @@ 'insurance_document_product_description' => 'Produktinformation / Produktbeschreibung', 'prrl_compliance_error_title' => 'Richtlinien der Pauschalreise nicht erfüllt', 'prrl_compliance_error_message' => 'Wir sind ein Reiseveranstalter für Pauschalreisen, daher haben Sie bitte Verständnis, dass wir keine einzelnen Reisebausteine anbieten. Ihre ausgewählten Reiseleistungen entsprechen leider nicht der Richtlinien einer Pauschalreise. Bitte buchen Sie mindestens zwei Reiseleistungen (z.B. Hotel und Mietwagen, Hotel und Flug oder Mietwagen und Flug)', + 'general_entry_requirements' => 'Allgemeine Einreisebestimmungen', + 'travel_information_intro' => 'Bitte prüfen Sie unten die Einreise- und Gesundheitsbestimmungen, die für die gewählten Reiseziele gelten.', + 'travel_information_health' => 'Gesundheit', + 'travel_information_entry_requirements' => 'Einreisebestimmungen', + 'travel_information_visa_requirements' => 'Visabestimmungen', + 'travel_information_transit_visa_requirements' => 'Transitvisabestimmungen', + 'travel_information_confirmation' => 'Ich bestätige, dass ich die oben genannten Einreise- und Gesundheitsbestimmungen gelesen habe', + 'travel_information_data_not_found' => 'Daten nicht gefunden', + 'travel_information_combination_title' => 'Reiseziel :destination / Nationalität :nationality', ], 'dates' => [ 'select' => 'Auswählen', diff --git a/lang/en/page.php b/lang/en/page.php index 543eacef..e3b58ccb 100644 --- a/lang/en/page.php +++ b/lang/en/page.php @@ -110,6 +110,15 @@ 'insurance_document_product_description' => 'Product Information / Product Description', 'prrl_compliance_error_title' => 'Package tour guidelines not fulfilled', 'prrl_compliance_error_message' => 'Please be aware that we do not offer individual travel components on this deal. Your selected travel services cannot be purchased individually in this package. Please re-search to book the full package, or alternatively use the contact us feature to get in touch with our excellent team.', + 'general_entry_requirements' => 'General entry requirements', + 'travel_information_intro' => 'Please check below Entry and Health Regulations that apply for the chosen destinations.', + 'travel_information_health' => 'Health', + 'travel_information_entry_requirements' => 'Entry Requirements', + 'travel_information_visa_requirements' => 'Visa Requirements', + 'travel_information_transit_visa_requirements' => 'Transit Visa Requirements', + 'travel_information_confirmation' => 'I confirm that I read Entry and Health Regulations above', + 'travel_information_data_not_found' => 'Data not found', + 'travel_information_combination_title' => 'Destination :destination / Nationality :nationality', ], 'dates' => [ diff --git a/lang/es/page.php b/lang/es/page.php index 13b76f2c..5b466a32 100644 --- a/lang/es/page.php +++ b/lang/es/page.php @@ -108,6 +108,15 @@ 'insurance_documents' => 'Documentos del seguro', 'insurance_document_ipid' => 'Documento de información del producto de seguro', 'insurance_document_product_description' => 'Información del producto / Descripción del producto', + 'general_entry_requirements' => 'Requisitos generales de entrada', + 'travel_information_intro' => 'Revise a continuación las normas de entrada y salud aplicables a los destinos elegidos.', + 'travel_information_health' => 'Salud', + 'travel_information_entry_requirements' => 'Requisitos de entrada', + 'travel_information_visa_requirements' => 'Requisitos de visado', + 'travel_information_transit_visa_requirements' => 'Requisitos de visado de tránsito', + 'travel_information_confirmation' => 'Confirmo que he leído las normas de entrada y salud anteriores', + 'travel_information_data_not_found' => 'Datos no encontrados', + 'travel_information_combination_title' => 'Destino :destination / Nacionalidad :nationality', ], 'dates' => [ 'select' => 'Seleccionar', diff --git a/lang/fr/page.php b/lang/fr/page.php index 6957bd4a..f896078c 100644 --- a/lang/fr/page.php +++ b/lang/fr/page.php @@ -110,6 +110,15 @@ 'insurance_document_product_description' => 'Information produit / Description du produit', 'prrl_compliance_error_title' => 'Les règles de voyages à forfait ne sont pas remplies', 'prrl_compliance_error_message' => 'Veuillez noter que nous ne proposons pas d’éléments de voyage individuels dans le cadre de cette offre. Les services de voyage que vous avez sélectionnés ne peuvent pas être achetés individuellement dans le cadre de ce forfait. Veuillez effectuer une nouvelle recherche pour réserver le forfait complet, ou utilisez la fonction « contactez-nous » pour prendre contact avec notre excellente équipe.', + 'general_entry_requirements' => 'Conditions générales d’entrée', + 'travel_information_intro' => 'Veuillez vérifier ci-dessous les réglementations d’entrée et de santé applicables aux destinations choisies.', + 'travel_information_health' => 'Santé', + 'travel_information_entry_requirements' => 'Conditions d’entrée', + 'travel_information_visa_requirements' => 'Conditions de visa', + 'travel_information_transit_visa_requirements' => 'Conditions de visa de transit', + 'travel_information_confirmation' => 'Je confirme avoir lu les réglementations d’entrée et de santé ci-dessus', + 'travel_information_data_not_found' => 'Données introuvables', + 'travel_information_combination_title' => 'Destination :destination / Nationalité :nationality', ], 'dates' => [ 'select' => 'Sélectionner', diff --git a/src/Actions/TravelInformation/LoadTravelInformationAction.php b/src/Actions/TravelInformation/LoadTravelInformationAction.php new file mode 100644 index 00000000..1fe7623a --- /dev/null +++ b/src/Actions/TravelInformation/LoadTravelInformationAction.php @@ -0,0 +1,76 @@ +|array $destinationCountries + * @return Collection + */ + public function run(Checkout $checkout, Collection|array $destinationCountries, string $language): Collection + { + $destinations = collect($destinationCountries) + ->filter(fn (mixed $country): bool => is_string($country) && trim($country) !== '') + ->map(fn (string $country): string => $this->normalizeCountryCode($country)) + ->unique() + ->values(); + + $nationalities = $checkout->getPaxInfo() + ->map(fn ($pax): ?string => $pax->nationalityCountryCode ?: $pax->nationality) + ->filter(fn (mixed $country): bool => is_string($country) && trim($country) !== '') + ->map(fn (string $country): string => $this->normalizeCountryCode($country)) + ->unique() + ->values(); + + if ($destinations->isEmpty() || $nationalities->isEmpty()) { + return new Collection; + } + + $content = $this->loadContent($destinations, $nationalities, $language); + + return $destinations + ->crossJoin($nationalities) + ->map(fn (array $pair): TravelInformationCombination => new TravelInformationCombination( + destinationCountryCode: $pair[0], + nationalityCountryCode: $pair[1], + content: $content->forCombination($pair[0], $pair[1]), + )); + } + + /** + * @param Collection $destinationCountryCodes + * @param Collection $nationalityCountryCodes + */ + private function loadContent(Collection $destinationCountryCodes, Collection $nationalityCountryCodes, string $language): PassolutionContentResponse + { + try { + return PassolutionConnector::make() + ->send(new GetContentRequest($destinationCountryCodes, $nationalityCountryCodes, $language)) + ->dto(); + } catch (Throwable $throwable) { + report($throwable); + + return PassolutionContentResponse::fromPayload([]); + } + } + + private function normalizeCountryCode(string $country): string + { + return str($country) + ->trim() + ->before('-') + ->upper() + ->toString(); + } +} diff --git a/src/Dtos/TravelInformation/TravelInformationCombination.php b/src/Dtos/TravelInformation/TravelInformationCombination.php new file mode 100644 index 00000000..2da29038 --- /dev/null +++ b/src/Dtos/TravelInformation/TravelInformationCombination.php @@ -0,0 +1,24 @@ +content->title() ?? trans('checkout::page.trip_details.travel_information_combination_title', [ + 'destination' => $this->destinationCountryCode, + 'nationality' => $this->nationalityCountryCode, + ]); + } +} diff --git a/src/Enums/Section.php b/src/Enums/Section.php index c782e0b9..7287dece 100644 --- a/src/Enums/Section.php +++ b/src/Enums/Section.php @@ -18,6 +18,7 @@ * @method bool isInsurance() * @method bool isActivity() * @method bool isTermsAndConditions() + * @method bool isTravelInformation() */ enum Section: string { @@ -32,6 +33,7 @@ enum Section: string case Insurance = 'insurance'; case Activity = 'activity'; case TermsAndConditions = 'terms-and-conditions'; + case TravelInformation = 'travel-information'; /** * Customize the labels of the enum values. @@ -50,6 +52,7 @@ protected static function setLabels(): array self::Insurance->value => trans('checkout::page.trip_details.Insurance'), self::Activity->value => trans('checkout::page.trip_details.activities'), self::TermsAndConditions->value => trans('checkout::page.trip_details.important_information'), + self::TravelInformation->value => trans('checkout::page.trip_details.general_entry_requirements'), ]; } } diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php new file mode 100644 index 00000000..e87499e1 --- /dev/null +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php @@ -0,0 +1,17 @@ +onRequest = new OnRequestResponseEntity; + $this->onRequest ??= new OnRequestResponseEntity; + $this->travelInformation ??= new TravelInformationResponseEntity; } /** diff --git a/src/Integrations/Passolution/Connectors/PassolutionConnector.php b/src/Integrations/Passolution/Connectors/PassolutionConnector.php new file mode 100644 index 00000000..3a9618ef --- /dev/null +++ b/src/Integrations/Passolution/Connectors/PassolutionConnector.php @@ -0,0 +1,41 @@ + + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } + + protected function defaultAuth(): TokenAuthenticator + { + return new TokenAuthenticator(Config::string('checkout.integrations.passolution.token')); + } +} diff --git a/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php b/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php new file mode 100644 index 00000000..a1991f03 --- /dev/null +++ b/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php @@ -0,0 +1,283 @@ + ['health', 'healthInformation', 'health_info', 'vaccination', 'vaccinations'], + 'entry' => ['entry', 'entryRequirements', 'entry_requirements', 'conditions'], + 'visa' => ['visa', 'visaRequirements', 'visa_requirements'], + 'transit_visa' => ['transit', 'transitVisa', 'transitvisa', 'transitVisaRequirements', 'transit_visa_requirements'], + ]; + + /** + * @param array $payload + */ + public function __construct( + public array $payload = [], + ) {} + + /** + * Create the DTO from the full API response without assuming a rigid vendor schema. + * + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + return new self(payload: $payload); + } + + public function forCombination(string $destinationCountryCode, string $nationalityCountryCode): self + { + $combinationPayload = $this->findCombinationPayload( + payload: $this->payload, + destinationCountryCode: $this->normalizeCountryValue($destinationCountryCode), + nationalityCountryCode: $this->normalizeCountryValue($nationalityCountryCode), + ); + + return new self(payload: is_array($combinationPayload) ? $combinationPayload : $this->payload); + } + + public function health(): ?string + { + return $this->sectionContent('health'); + } + + public function title(): ?string + { + $title = Arr::get($this->payload, 'title'); + + return is_string($title) && trim($title) !== '' ? $title : null; + } + + public function entryRequirements(): ?string + { + return $this->sectionContent('entry'); + } + + public function visaRequirements(): ?string + { + return $this->sectionContent('visa'); + } + + public function transitVisaRequirements(): ?string + { + return $this->sectionContent('transit_visa'); + } + + private function sectionContent(string $section): ?string + { + $aliases = self::SECTION_ALIASES[$section] ?? [$section]; + + foreach ($aliases as $alias) { + $value = Arr::get($this->payload, $alias); + + if ($value !== null) { + return $this->stringify($value); + } + } + + $value = $this->findByAlias($this->payload, $aliases); + + return $value === null ? null : $this->stringify($value); + } + + /** + * @param array $payload + */ + private function findCombinationPayload(array $payload, string $destinationCountryCode, string $nationalityCountryCode): mixed + { + foreach ($payload as $key => $value) { + if (is_array($value) && $this->keyMatchesCountry($key, $destinationCountryCode)) { + $nestedByNationality = $this->findPayloadByCountryKey($value, $nationalityCountryCode); + + if (is_array($nestedByNationality)) { + return $nestedByNationality; + } + } + + if (! is_array($value)) { + continue; + } + + if ($this->payloadMatchesCombination($value, $destinationCountryCode, $nationalityCountryCode)) { + return $value; + } + + $nested = $this->findCombinationPayload($value, $destinationCountryCode, $nationalityCountryCode); + + if (is_array($nested)) { + return $nested; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function findPayloadByCountryKey(array $payload, string $countryCode): mixed + { + foreach ($payload as $key => $value) { + if (is_array($value) && $this->keyMatchesCountry($key, $countryCode)) { + return $value; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function payloadMatchesCombination(array $payload, string $destinationCountryCode, string $nationalityCountryCode): bool + { + return $this->payloadHasCountryValue($payload, [ + 'country', + 'countries', + 'destination', + 'destinationCountry', + 'destination_country', + 'destinationCountryCode', + 'destination_country_code', + ], $destinationCountryCode) + && $this->payloadHasCountryValue($payload, [ + 'nat', + 'nationality', + 'nationalities', + 'nationalityCountry', + 'nationality_country', + 'nationalityCountryCode', + 'nationality_country_code', + ], $nationalityCountryCode); + } + + /** + * @param array $payload + * @param array $aliases + */ + private function payloadHasCountryValue(array $payload, array $aliases, string $countryCode): bool + { + $normalizedAliases = array_map(fn (string $alias): string => $this->normalizeKey($alias), $aliases); + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! in_array($this->normalizeKey($key), $normalizedAliases, true)) { + continue; + } + + if ($this->countryValueMatches($value, $countryCode)) { + return true; + } + } + + return false; + } + + private function keyMatchesCountry(string|int $key, string $countryCode): bool + { + return is_string($key) && $this->normalizeCountryValue($key) === $countryCode; + } + + private function countryValueMatches(mixed $value, string $countryCode): bool + { + if (is_string($value)) { + return collect(explode(',', $value)) + ->map(fn (string $country): string => $this->normalizeCountryValue($country)) + ->contains($countryCode); + } + + if (is_array($value)) { + return collect($value) + ->filter(fn (mixed $country): bool => is_string($country)) + ->map(fn (string $country): string => $this->normalizeCountryValue($country)) + ->contains($countryCode); + } + + return false; + } + + /** + * @param array $payload + * @param array $aliases + */ + private function findByAlias(array $payload, array $aliases): mixed + { + $normalizedAliases = array_map(fn (string $alias): string => $this->normalizeKey($alias), $aliases); + + foreach ($payload as $key => $value) { + if (is_string($key) && in_array($this->normalizeKey($key), $normalizedAliases, true)) { + return $value; + } + + if (is_array($value)) { + $nested = $this->findByAlias($value, $aliases); + + if ($nested !== null) { + return $nested; + } + } + } + + return null; + } + + private function stringify(mixed $value): ?string + { + if (is_string($value)) { + return trim($value) !== '' ? $value : null; + } + + if (! is_array($value)) { + return null; + } + + $parts = $this->extractTextParts($value); + + return $parts === [] ? null : implode("\n\n", $parts); + } + + /** + * @param array $payload + * @return array + */ + private function extractTextParts(array $payload): array + { + $parts = []; + $preferredTextKeys = ['text', 'html', 'content', 'body', 'description', 'value']; + + foreach ($payload as $key => $value) { + if (is_string($value) && trim($value) !== '') { + if (is_int($key) || in_array($this->normalizeKey((string) $key), $preferredTextKeys, true)) { + $parts[] = $value; + } + } + + if (is_array($value)) { + $parts = [...$parts, ...$this->extractTextParts($value)]; + } + } + + return array_values(array_unique($parts)); + } + + private function normalizeKey(string $key): string + { + return str($key)->lower()->replace(['-', '_', ' '], '')->toString(); + } + + private function normalizeCountryValue(string $country): string + { + return str($country) + ->trim() + ->before('-') + ->lower() + ->toString(); + } +} diff --git a/src/Integrations/Passolution/Requests/GetContentRequest.php b/src/Integrations/Passolution/Requests/GetContentRequest.php new file mode 100644 index 00000000..89f8e3e5 --- /dev/null +++ b/src/Integrations/Passolution/Requests/GetContentRequest.php @@ -0,0 +1,52 @@ + $destinationCountryCodes + * @param Collection $nationalityCountryCodes + */ + public function __construct( + private readonly Collection $destinationCountryCodes, + private readonly Collection $nationalityCountryCodes, + private readonly string $language, + ) {} + + public function resolveEndpoint(): string + { + return '/content/all/text'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'lang' => strtolower($this->language), + 'countries' => $this->destinationCountryCodes->map(fn (string $country): string => strtolower($country))->implode(','), + 'nat' => $this->nationalityCountryCodes->map(fn (string $country): string => strtolower($country))->implode(','), + ]; + } + + public function createDtoFromResponse(Response $response): PassolutionContentResponse + { + if (! $response->ok()) { + return PassolutionContentResponse::fromPayload([]); + } + + return PassolutionContentResponse::fromPayload($response->array()); + } +} diff --git a/src/Livewire/BaseCheckoutComponent.php b/src/Livewire/BaseCheckoutComponent.php index 6572f8d0..8b23ed3b 100644 --- a/src/Livewire/BaseCheckoutComponent.php +++ b/src/Livewire/BaseCheckoutComponent.php @@ -218,6 +218,7 @@ protected function sectionFlow(): array Section::AdditionalService, Section::Insurance, Section::TermsAndConditions, + Section::TravelInformation, Section::PaymentOptions, ]; } diff --git a/src/Livewire/PaymentOptionsSection.php b/src/Livewire/PaymentOptionsSection.php index f5d6abf9..9a045c29 100644 --- a/src/Livewire/PaymentOptionsSection.php +++ b/src/Livewire/PaymentOptionsSection.php @@ -124,7 +124,7 @@ private function hasAcceptedOnRequestTerms(): bool /** * Listen for the 'traveller-processed' event to determine if the promo code section should be expanded or completed. */ - #[On(Section::TermsAndConditions->value)] + #[On(Section::TravelInformation->value)] public function listen(): void { $this->expand(Section::PaymentOptions); diff --git a/src/Livewire/PaymentPage.php b/src/Livewire/PaymentPage.php index ba8c356f..93af81c0 100644 --- a/src/Livewire/PaymentPage.php +++ b/src/Livewire/PaymentPage.php @@ -3,6 +3,7 @@ namespace Nezasa\Checkout\Livewire; use Illuminate\Contracts\View\View; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Uri; use Nezasa\Checkout\Actions\Checkout\FindCheckoutModelAction; use Nezasa\Checkout\Actions\Checkout\GetPaymentProviderAction; @@ -10,6 +11,7 @@ use Nezasa\Checkout\Actions\TripDetails\CallTripDetailsAction; use Nezasa\Checkout\Dtos\Planner\ItinerarySummary; use Nezasa\Checkout\Insurances\Handlers\InsuranceHandler; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; use Nezasa\Checkout\Payments\Contracts\PaymentContract; use Nezasa\Checkout\Payments\Dtos\PaymentAsset; use Nezasa\Checkout\Payments\Handlers\PaymentInitiationHandler; @@ -72,6 +74,13 @@ protected function initializeRequirements(): bool return false; } + if ($this->requiresTravelInformationConfirmation($result->regulatoryInformation) + && data_get($this->model->data, 'travel_information_confirmed') !== true) { + $this->redirect(route('traveler-details', $this->getParams()->toArray())); + + return false; + } + $this->itinerary = resolve(SummarizeItineraryAction::class)->run( itineraryResponse: $result->itinerary, checkoutResponse: $result->checkout, @@ -83,6 +92,13 @@ protected function initializeRequirements(): bool return true; } + private function requiresTravelInformationConfirmation(RegulatoryInformationResponse $regulatoryInformation): bool + { + return $regulatoryInformation->travelInformation?->confirmationEnabled === true + && Config::boolean('checkout.integrations.passolution.active') + && filled(Config::string('checkout.integrations.passolution.token')); + } + /** * Handle the payment process by preparing the payment data and initializing the payment gateway. */ diff --git a/src/Livewire/TravelInformationSection.php b/src/Livewire/TravelInformationSection.php new file mode 100644 index 00000000..fbbf8065 --- /dev/null +++ b/src/Livewire/TravelInformationSection.php @@ -0,0 +1,120 @@ +> + */ + public array $combinations = []; + + public function mount(LoadTravelInformationAction $loadTravelInformationAction): void + { + $this->travelInformationConfirmed = (bool) data_get($this->model->data, 'travel_information_confirmed', false); + + if ($this->shouldRender() && ($this->isExpanded || $this->isCompleted || $this->model->isCompleted(Section::TermsAndConditions))) { + $this->loadCombinations($loadTravelInformationAction); + } + } + + public function render(): View + { + /** @phpstan-ignore-next-line */ + return view('checkout::blades.travel-information-section'); + } + + public function shouldRender(): bool + { + return $this->regulatoryInformation->travelInformation?->confirmationEnabled === true + && Config::boolean('checkout.integrations.passolution.active') + && filled(Config::string('checkout.integrations.passolution.token')); + } + + public function toggleTravelInformationConfirmation(bool $value): void + { + $this->travelInformationConfirmed = $value; + $this->model->updateData(['travel_information_confirmed' => $value]); + + if ($value) { + $this->resetValidation('travelInformationConfirmed'); + } + } + + public function next(): void + { + if (! $this->shouldRender()) { + $this->markAsCompletedAdnCollapse(Section::TravelInformation); + $this->dispatch(Section::TravelInformation->value); + + return; + } + + $this->validate([ + 'travelInformationConfirmed' => ['required', 'accepted'], + ]); + + $this->model->updateData(['travel_information_confirmed' => true]); + $this->markAsCompletedAdnCollapse(Section::TravelInformation); + $this->dispatch(Section::TravelInformation->value); + } + + #[On(Section::TermsAndConditions->value)] + public function listen(LoadTravelInformationAction $loadTravelInformationAction): void + { + if (! $this->shouldRender()) { + $this->next(); + + return; + } + + $this->loadCombinations($loadTravelInformationAction); + $this->expand(Section::TravelInformation); + } + + /** + * @param array $sections + */ + #[On('sections-reset')] + public function resetSection(array $sections): void + { + if (! in_array(Section::TravelInformation->value, $sections, true)) { + return; + } + + $this->isCompleted = false; + $this->isExpanded = false; + } + + private function loadCombinations(LoadTravelInformationAction $loadTravelInformationAction): void + { + $this->combinations = $loadTravelInformationAction + ->run($this->model, $this->itinerary->destinationCountries, $this->lang ?? 'en') + ->map(fn (TravelInformationCombination $combination): array => [ + 'title' => $combination->title(), + 'health' => $combination->content->health(), + 'entry' => $combination->content->entryRequirements(), + 'visa' => $combination->content->visaRequirements(), + 'transit_visa' => $combination->content->transitVisaRequirements(), + ]) + ->values() + ->all(); + } +} diff --git a/src/Providers/CheckoutServiceProvider.php b/src/Providers/CheckoutServiceProvider.php index 369bb091..026c8c36 100644 --- a/src/Providers/CheckoutServiceProvider.php +++ b/src/Providers/CheckoutServiceProvider.php @@ -21,6 +21,7 @@ use Nezasa\Checkout\Livewire\Stepper; use Nezasa\Checkout\Livewire\TermsSection; use Nezasa\Checkout\Livewire\TravelerDetails; +use Nezasa\Checkout\Livewire\TravelInformationSection; use Nezasa\Checkout\Livewire\TripDetailsPage; use Nezasa\Checkout\Livewire\TripSummary; @@ -65,6 +66,7 @@ private function registerLivewireComponents(): void Livewire::component(name: 'activity-section', class: ActivitySection::class); Livewire::component(name: 'insurance-section', class: InsuranceSection::class); Livewire::component(name: 'terms-section', class: TermsSection::class); + Livewire::component(name: 'travel-information-section', class: TravelInformationSection::class); Livewire::component(name: 'trip-summary', class: TripSummary::class); Livewire::component(name: 'trip-details-page', class: TripDetailsPage::class); Livewire::component(name: 'payment-page', class: PaymentPage::class); diff --git a/src/Resources/Views/blades/index.blade.php b/src/Resources/Views/blades/index.blade.php index 82a63d41..4de3d222 100644 --- a/src/Resources/Views/blades/index.blade.php +++ b/src/Resources/Views/blades/index.blade.php @@ -58,6 +58,13 @@ :is-completed="$model->isCompleted(Section::TermsAndConditions)" :is-expanded="$model->isExpanded(Section::TermsAndConditions)" /> + @endif trans('checkout::page.trip_details.travel_information_health'), + 'entry' => trans('checkout::page.trip_details.travel_information_entry_requirements'), + 'visa' => trans('checkout::page.trip_details.travel_information_visa_requirements'), + 'transit_visa' => trans('checkout::page.trip_details.travel_information_transit_visa_requirements'), +]) + + +
+

+ {{ trans('checkout::page.trip_details.travel_information_intro') }} +

+ +
+ @foreach($sections as $key => $label) +
first) open @endif> + + {{ $label }} + + + +
+ @forelse($combinations as $combination) +
+

{{ $combination['title'] }}

+ +
+ @if(filled($combination[$key] ?? null)) + {{ str_replace(["\r\n", "\r"], "\n", $combination[$key]) }} + @else + {{ trans('checkout::page.trip_details.travel_information_data_not_found') }} + @endif +
+
+ @empty +
+ {{ trans('checkout::page.trip_details.travel_information_data_not_found') }} +
+ @endforelse +
+
+ @endforeach +
+ +
+ + + @error('travelInformationConfirmed') +

{{ trans('checkout::input.validations.agree_to_continue') }}

+ @enderror +
+ +
+
+ +
+
+ +
+
+
+
diff --git a/tests/Unit/Livewire/WorkflowSectionsTest.php b/tests/Unit/Livewire/WorkflowSectionsTest.php index ab0335a7..08af44c0 100644 --- a/tests/Unit/Livewire/WorkflowSectionsTest.php +++ b/tests/Unit/Livewire/WorkflowSectionsTest.php @@ -7,6 +7,7 @@ use Illuminate\Validation\ValidationException; use Nezasa\Checkout\Actions\Checkout\GetPaymentProviderAction; use Nezasa\Checkout\Actions\Checkout\VerifyAvailabilityAction; +use Nezasa\Checkout\Actions\TravelInformation\LoadTravelInformationAction; use Nezasa\Checkout\Dtos\Checkout\CheckoutParamsDto; use Nezasa\Checkout\Dtos\Planner\ItinerarySummary; use Nezasa\Checkout\Dtos\View\PaymentOption; @@ -18,14 +19,19 @@ use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\TermsAndConditionsResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\TextSectionResponseEntity; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\TravelInformationResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\PriceResponse; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Shared\Price; +use Nezasa\Checkout\Integrations\Passolution\Requests\GetContentRequest; use Nezasa\Checkout\Livewire\PaymentOptionsSection; use Nezasa\Checkout\Livewire\Stepper; use Nezasa\Checkout\Livewire\TermsSection; +use Nezasa\Checkout\Livewire\TravelInformationSection; use Nezasa\Checkout\Livewire\TripSummary; use Nezasa\Checkout\Models\Checkout; +use Saloon\Http\Faking\MockClient; +use Saloon\Http\Faking\MockResponse; final class ExposedTermsSectionForWorkflowTest extends TermsSection { @@ -89,7 +95,7 @@ function livewireWorkflowItinerary(?PriceResponse $price = null): ItinerarySumma ); } -function primeBaseCheckoutComponent(TripSummary|PaymentOptionsSection|TermsSection $component, Checkout $checkout): void +function primeBaseCheckoutComponent(TripSummary|PaymentOptionsSection|TermsSection|TravelInformationSection $component, Checkout $checkout): void { $component->model = $checkout; $component->checkoutId = $checkout->checkout_id; @@ -254,6 +260,98 @@ function primeBaseCheckoutComponent(TripSummary|PaymentOptionsSection|TermsSecti ->and($component->exposedRules())->not->toHaveKey('acceptedEuPrrlTerms'); }); +it('loads Passolution travel information for every destination and nationality combination', function (): void { + config()->set('checkout.integrations.passolution.active', true); + config()->set('checkout.integrations.passolution.token', 'test-token'); + + $mockClient = MockClient::global([ + GetContentRequest::class => MockResponse::make([ + 'items' => [ + [ + 'destinationCountryCode' => 'DE', + 'nationalityCountryCode' => 'IR', + 'title' => 'Destination Germany / Nationality Iran', + 'health' => ['text' => 'No vaccinations are required.'], + 'entryRequirements' => ['text' => 'A passport is required.'], + 'visaRequirements' => ['text' => 'No visa is required.'], + 'transitVisaRequirements' => ['text' => 'Transit visa is not required.'], + ], + ], + ]), + ]); + + $checkout = livewireWorkflowCheckout([ + 'paxInfo' => [ + [ + ['nationality' => 'IR-IRAN'], + ], + ], + 'status' => array_replace_recursive(Checkout::buildSectionStatus(), [ + Section::TermsAndConditions->value => ['isCompleted' => true], + ]), + ]); + $component = new TravelInformationSection; + primeBaseCheckoutComponent($component, $checkout); + $component->itinerary = livewireWorkflowItinerary(); + $component->regulatoryInformation = new RegulatoryInformationResponse( + travelInformation: new TravelInformationResponseEntity(confirmationEnabled: true) + ); + + $component->mount(new LoadTravelInformationAction); + + expect($component->shouldRender())->toBeTrue() + ->and($component->combinations)->toHaveCount(1) + ->and($component->combinations[0]['title'])->toBe('Destination Germany / Nationality Iran') + ->and($component->combinations[0]['health'])->toBe('No vaccinations are required.') + ->and($component->combinations[0]['entry'])->toBe('A passport is required.') + ->and($component->combinations[0]['visa'])->toBe('No visa is required.') + ->and($component->combinations[0]['transit_visa'])->toBe('Transit visa is not required.'); + + $mockClient->assertSent(function (mixed $request): bool { + if (! $request instanceof GetContentRequest) { + return false; + } + + expect($request->resolveEndpoint())->toBe('/content/all/text') + ->and($request->query()->all())->toBe([ + 'lang' => 'en', + 'countries' => 'de', + 'nat' => 'ir', + ]); + + return true; + }); +}); + +it('requires and persists travel information confirmation before continuing', function (): void { + config()->set('checkout.integrations.passolution.active', true); + config()->set('checkout.integrations.passolution.token', 'test-token'); + + $checkout = livewireWorkflowCheckout([ + 'paxInfo' => [ + [ + ['nationalityCountryCode' => 'EG'], + ], + ], + ]); + $component = new TravelInformationSection; + primeBaseCheckoutComponent($component, $checkout); + $component->itinerary = livewireWorkflowItinerary(); + $component->regulatoryInformation = new RegulatoryInformationResponse( + travelInformation: new TravelInformationResponseEntity(confirmationEnabled: true) + ); + + expect(fn () => $component->next())->toThrow(ValidationException::class); + + $component->toggleTravelInformationConfirmation(true); + $component->next(); + $checkout->refresh(); + + expect(data_get($checkout->data, 'travel_information_confirmed'))->toBeTrue() + ->and($component->isCompleted)->toBeTrue() + ->and($component->isExpanded)->toBeFalse(); +}); + it('loads EU-PRRL terms acceptance only for the current content hash', function (): void { $acceptedEuPrrl = new EuPrrlResponseEntity( generalTermsConfirmationEnabled: true, From 2f50da0a69cb7e9ccabe57ec06a56d8d5c3b71be Mon Sep 17 00:00:00 2001 From: Azim Kordpour Date: Thu, 25 Jun 2026 16:45:45 +0200 Subject: [PATCH 2/7] MLZ-4892: Refactor Passolution integration by introducing `PassolutionRecordResponse` and `PassolutionSectionResponse` DTOs; update `TravelInformationCombination` logic and tests to streamline content handling. --- .../LoadTravelInformationAction.php | 2 +- .../TravelInformationCombination.php | 30 +- .../Responses/PassolutionContentResponse.php | 270 +----------------- .../Responses/PassolutionRecordResponse.php | 73 +++++ .../Responses/PassolutionSectionResponse.php | 17 ++ src/Livewire/TravelInformationSection.php | 8 +- tests/Unit/Livewire/WorkflowSectionsTest.php | 14 +- 7 files changed, 143 insertions(+), 271 deletions(-) create mode 100644 src/Integrations/Passolution/Dtos/Responses/PassolutionRecordResponse.php create mode 100644 src/Integrations/Passolution/Dtos/Responses/PassolutionSectionResponse.php diff --git a/src/Actions/TravelInformation/LoadTravelInformationAction.php b/src/Actions/TravelInformation/LoadTravelInformationAction.php index 1fe7623a..3761ff27 100644 --- a/src/Actions/TravelInformation/LoadTravelInformationAction.php +++ b/src/Actions/TravelInformation/LoadTravelInformationAction.php @@ -44,7 +44,7 @@ public function run(Checkout $checkout, Collection|array $destinationCountries, ->map(fn (array $pair): TravelInformationCombination => new TravelInformationCombination( destinationCountryCode: $pair[0], nationalityCountryCode: $pair[1], - content: $content->forCombination($pair[0], $pair[1]), + record: $content->recordForCombination($pair[0], $pair[1]), )); } diff --git a/src/Dtos/TravelInformation/TravelInformationCombination.php b/src/Dtos/TravelInformation/TravelInformationCombination.php index 2da29038..13c09207 100644 --- a/src/Dtos/TravelInformation/TravelInformationCombination.php +++ b/src/Dtos/TravelInformation/TravelInformationCombination.php @@ -4,21 +4,45 @@ namespace Nezasa\Checkout\Dtos\TravelInformation; -use Nezasa\Checkout\Integrations\Passolution\Dtos\Responses\PassolutionContentResponse; +use Nezasa\Checkout\Integrations\Passolution\Dtos\Responses\PassolutionRecordResponse; class TravelInformationCombination { public function __construct( public readonly string $destinationCountryCode, public readonly string $nationalityCountryCode, - public readonly PassolutionContentResponse $content, + public readonly ?PassolutionRecordResponse $record = null, ) {} public function title(): string { - return $this->content->title() ?? trans('checkout::page.trip_details.travel_information_combination_title', [ + if ($this->record !== null && $this->record->title !== null) { + return $this->record->title; + } + + return trans('checkout::page.trip_details.travel_information_combination_title', [ 'destination' => $this->destinationCountryCode, 'nationality' => $this->nationalityCountryCode, ]); } + + public function health(): ?string + { + return $this->record?->healthContent(); + } + + public function entry(): ?string + { + return $this->record?->entryContent(); + } + + public function visa(): ?string + { + return $this->record?->visaContent(); + } + + public function transitVisa(): ?string + { + return $this->record?->transitVisaContent(); + } } diff --git a/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php b/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php index a1991f03..73d08008 100644 --- a/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php +++ b/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php @@ -4,280 +4,38 @@ namespace Nezasa\Checkout\Integrations\Passolution\Dtos\Responses; -use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Nezasa\Checkout\Dtos\BaseDto; class PassolutionContentResponse extends BaseDto { - private const SECTION_ALIASES = [ - 'health' => ['health', 'healthInformation', 'health_info', 'vaccination', 'vaccinations'], - 'entry' => ['entry', 'entryRequirements', 'entry_requirements', 'conditions'], - 'visa' => ['visa', 'visaRequirements', 'visa_requirements'], - 'transit_visa' => ['transit', 'transitVisa', 'transitvisa', 'transitVisaRequirements', 'transit_visa_requirements'], - ]; - /** - * @param array $payload + * @param Collection $records */ public function __construct( - public array $payload = [], + public Collection $records = new Collection, ) {} /** - * Create the DTO from the full API response without assuming a rigid vendor schema. - * * @param array $payload */ public static function fromPayload(array $payload): self { - return new self(payload: $payload); - } + $records = $payload['records'] ?? []; - public function forCombination(string $destinationCountryCode, string $nationalityCountryCode): self - { - $combinationPayload = $this->findCombinationPayload( - payload: $this->payload, - destinationCountryCode: $this->normalizeCountryValue($destinationCountryCode), - nationalityCountryCode: $this->normalizeCountryValue($nationalityCountryCode), + return new self( + records: collect(is_array($records) ? $records : []) + ->filter(fn (mixed $record): bool => is_array($record)) + ->map(fn (array $record): PassolutionRecordResponse => PassolutionRecordResponse::fromPayload($record)) + ->values() ); - - return new self(payload: is_array($combinationPayload) ? $combinationPayload : $this->payload); - } - - public function health(): ?string - { - return $this->sectionContent('health'); - } - - public function title(): ?string - { - $title = Arr::get($this->payload, 'title'); - - return is_string($title) && trim($title) !== '' ? $title : null; - } - - public function entryRequirements(): ?string - { - return $this->sectionContent('entry'); - } - - public function visaRequirements(): ?string - { - return $this->sectionContent('visa'); - } - - public function transitVisaRequirements(): ?string - { - return $this->sectionContent('transit_visa'); - } - - private function sectionContent(string $section): ?string - { - $aliases = self::SECTION_ALIASES[$section] ?? [$section]; - - foreach ($aliases as $alias) { - $value = Arr::get($this->payload, $alias); - - if ($value !== null) { - return $this->stringify($value); - } - } - - $value = $this->findByAlias($this->payload, $aliases); - - return $value === null ? null : $this->stringify($value); - } - - /** - * @param array $payload - */ - private function findCombinationPayload(array $payload, string $destinationCountryCode, string $nationalityCountryCode): mixed - { - foreach ($payload as $key => $value) { - if (is_array($value) && $this->keyMatchesCountry($key, $destinationCountryCode)) { - $nestedByNationality = $this->findPayloadByCountryKey($value, $nationalityCountryCode); - - if (is_array($nestedByNationality)) { - return $nestedByNationality; - } - } - - if (! is_array($value)) { - continue; - } - - if ($this->payloadMatchesCombination($value, $destinationCountryCode, $nationalityCountryCode)) { - return $value; - } - - $nested = $this->findCombinationPayload($value, $destinationCountryCode, $nationalityCountryCode); - - if (is_array($nested)) { - return $nested; - } - } - - return null; - } - - /** - * @param array $payload - */ - private function findPayloadByCountryKey(array $payload, string $countryCode): mixed - { - foreach ($payload as $key => $value) { - if (is_array($value) && $this->keyMatchesCountry($key, $countryCode)) { - return $value; - } - } - - return null; - } - - /** - * @param array $payload - */ - private function payloadMatchesCombination(array $payload, string $destinationCountryCode, string $nationalityCountryCode): bool - { - return $this->payloadHasCountryValue($payload, [ - 'country', - 'countries', - 'destination', - 'destinationCountry', - 'destination_country', - 'destinationCountryCode', - 'destination_country_code', - ], $destinationCountryCode) - && $this->payloadHasCountryValue($payload, [ - 'nat', - 'nationality', - 'nationalities', - 'nationalityCountry', - 'nationality_country', - 'nationalityCountryCode', - 'nationality_country_code', - ], $nationalityCountryCode); - } - - /** - * @param array $payload - * @param array $aliases - */ - private function payloadHasCountryValue(array $payload, array $aliases, string $countryCode): bool - { - $normalizedAliases = array_map(fn (string $alias): string => $this->normalizeKey($alias), $aliases); - - foreach ($payload as $key => $value) { - if (! is_string($key) || ! in_array($this->normalizeKey($key), $normalizedAliases, true)) { - continue; - } - - if ($this->countryValueMatches($value, $countryCode)) { - return true; - } - } - - return false; - } - - private function keyMatchesCountry(string|int $key, string $countryCode): bool - { - return is_string($key) && $this->normalizeCountryValue($key) === $countryCode; - } - - private function countryValueMatches(mixed $value, string $countryCode): bool - { - if (is_string($value)) { - return collect(explode(',', $value)) - ->map(fn (string $country): string => $this->normalizeCountryValue($country)) - ->contains($countryCode); - } - - if (is_array($value)) { - return collect($value) - ->filter(fn (mixed $country): bool => is_string($country)) - ->map(fn (string $country): string => $this->normalizeCountryValue($country)) - ->contains($countryCode); - } - - return false; } - /** - * @param array $payload - * @param array $aliases - */ - private function findByAlias(array $payload, array $aliases): mixed + public function recordForCombination(string $destinationCountryCode, string $nationalityCountryCode): ?PassolutionRecordResponse { - $normalizedAliases = array_map(fn (string $alias): string => $this->normalizeKey($alias), $aliases); - - foreach ($payload as $key => $value) { - if (is_string($key) && in_array($this->normalizeKey($key), $normalizedAliases, true)) { - return $value; - } - - if (is_array($value)) { - $nested = $this->findByAlias($value, $aliases); - - if ($nested !== null) { - return $nested; - } - } - } - - return null; - } - - private function stringify(mixed $value): ?string - { - if (is_string($value)) { - return trim($value) !== '' ? $value : null; - } - - if (! is_array($value)) { - return null; - } - - $parts = $this->extractTextParts($value); - - return $parts === [] ? null : implode("\n\n", $parts); - } - - /** - * @param array $payload - * @return array - */ - private function extractTextParts(array $payload): array - { - $parts = []; - $preferredTextKeys = ['text', 'html', 'content', 'body', 'description', 'value']; - - foreach ($payload as $key => $value) { - if (is_string($value) && trim($value) !== '') { - if (is_int($key) || in_array($this->normalizeKey((string) $key), $preferredTextKeys, true)) { - $parts[] = $value; - } - } - - if (is_array($value)) { - $parts = [...$parts, ...$this->extractTextParts($value)]; - } - } - - return array_values(array_unique($parts)); - } - - private function normalizeKey(string $key): string - { - return str($key)->lower()->replace(['-', '_', ' '], '')->toString(); - } - - private function normalizeCountryValue(string $country): string - { - return str($country) - ->trim() - ->before('-') - ->lower() - ->toString(); + return $this->records->first( + fn (PassolutionRecordResponse $record): bool => $record->destination === $destinationCountryCode + && $record->nationality === $nationalityCountryCode + ); } } diff --git a/src/Integrations/Passolution/Dtos/Responses/PassolutionRecordResponse.php b/src/Integrations/Passolution/Dtos/Responses/PassolutionRecordResponse.php new file mode 100644 index 00000000..7832ac50 --- /dev/null +++ b/src/Integrations/Passolution/Dtos/Responses/PassolutionRecordResponse.php @@ -0,0 +1,73 @@ + $payload + */ + public static function fromPayload(array $payload): self + { + return new self( + destination: $payload['destination'] ?? null, + nationality: $payload['nationality'] ?? data_get($payload, 'traveller.nationality'), + title: $payload['title'] ?? null, + entry: self::section($payload['entry'] ?? null), + visa: self::section($payload['visa'] ?? null), + transitVisa: self::section($payload['transit_visa'] ?? null), + health: self::section($payload['health'] ?? null), + ); + } + + public function healthContent(): ?string + { + return $this->health?->content; + } + + public function entryContent(): ?string + { + return $this->entry?->content; + } + + public function visaContent(): ?string + { + return $this->visa?->content; + } + + public function transitVisaContent(): ?string + { + return $this->transitVisa?->content; + } + + /** + * @param array|null $payload + */ + private static function section(?array $payload): ?PassolutionSectionResponse + { + if ($payload === null) { + return null; + } + + return new PassolutionSectionResponse( + language: $payload['language'] ?? null, + title: $payload['title'] ?? null, + content: $payload['content'] ?? null, + updatedAt: $payload['updated_at'] ?? null, + ); + } +} diff --git a/src/Integrations/Passolution/Dtos/Responses/PassolutionSectionResponse.php b/src/Integrations/Passolution/Dtos/Responses/PassolutionSectionResponse.php new file mode 100644 index 00000000..57bfed3f --- /dev/null +++ b/src/Integrations/Passolution/Dtos/Responses/PassolutionSectionResponse.php @@ -0,0 +1,17 @@ +run($this->model, $this->itinerary->destinationCountries, $this->lang ?? 'en') ->map(fn (TravelInformationCombination $combination): array => [ 'title' => $combination->title(), - 'health' => $combination->content->health(), - 'entry' => $combination->content->entryRequirements(), - 'visa' => $combination->content->visaRequirements(), - 'transit_visa' => $combination->content->transitVisaRequirements(), + 'health' => $combination->health(), + 'entry' => $combination->entry(), + 'visa' => $combination->visa(), + 'transit_visa' => $combination->transitVisa(), ]) ->values() ->all(); diff --git a/tests/Unit/Livewire/WorkflowSectionsTest.php b/tests/Unit/Livewire/WorkflowSectionsTest.php index 08af44c0..da8ea83e 100644 --- a/tests/Unit/Livewire/WorkflowSectionsTest.php +++ b/tests/Unit/Livewire/WorkflowSectionsTest.php @@ -266,15 +266,15 @@ function primeBaseCheckoutComponent(TripSummary|PaymentOptionsSection|TermsSecti $mockClient = MockClient::global([ GetContentRequest::class => MockResponse::make([ - 'items' => [ + 'records' => [ [ - 'destinationCountryCode' => 'DE', - 'nationalityCountryCode' => 'IR', + 'destination' => 'DE', + 'nationality' => 'IR', 'title' => 'Destination Germany / Nationality Iran', - 'health' => ['text' => 'No vaccinations are required.'], - 'entryRequirements' => ['text' => 'A passport is required.'], - 'visaRequirements' => ['text' => 'No visa is required.'], - 'transitVisaRequirements' => ['text' => 'Transit visa is not required.'], + 'entry' => ['content' => 'A passport is required.'], + 'visa' => ['content' => 'No visa is required.'], + 'transit_visa' => ['content' => 'Transit visa is not required.'], + 'health' => ['content' => 'No vaccinations are required.'], ], ], ]), From d2b8f4dde170d923662c0f12274f7215ae611f47 Mon Sep 17 00:00:00 2001 From: Azim Kordpour Date: Thu, 25 Jun 2026 16:55:00 +0200 Subject: [PATCH 3/7] MLZ-4892: Extend travel information confirmation handling; update DTOs, blades, and tests to support dynamic titles, intros, and checkbox text from regulatory information. --- .../TravelInformationResponseEntity.php | 13 +++++++-- .../travel-information-section.blade.php | 15 ++++++++-- .../TripDetails/CallTripDetailsActionTest.php | 29 ++++++++++++++----- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php index e87499e1..8eaf6c05 100644 --- a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php @@ -12,6 +12,15 @@ class TravelInformationResponseEntity extends BaseDto * Create a new instance of the TravelInformationResponseEntity. */ public function __construct( - public bool $confirmationEnabled = true, - ) {} + public bool $confirmationEnabled = false, + public ?string $title = null, + public ?string $intro = null, + public ?string $checkboxText = null, + ) { + // todo: remove this after testing + $this->confirmationEnabled = true; + $this->title = 'Test: Travel Information'; + $this->intro = 'Test: Please confirm your travel information.'; + $this->checkboxText = 'Test: I confirm that the travel information provided is correct.'; + } } diff --git a/src/Resources/Views/blades/travel-information-section.blade.php b/src/Resources/Views/blades/travel-information-section.blade.php index 1860e75c..25c0d169 100644 --- a/src/Resources/Views/blades/travel-information-section.blade.php +++ b/src/Resources/Views/blades/travel-information-section.blade.php @@ -6,9 +6,10 @@ 'visa' => trans('checkout::page.trip_details.travel_information_visa_requirements'), 'transit_visa' => trans('checkout::page.trip_details.travel_information_transit_visa_requirements'), ]) +@php($travelInformation = $regulatoryInformation->travelInformation)

- {{ trans('checkout::page.trip_details.travel_information_intro') }} + @if(filled($travelInformation?->intro)) + {!! $travelInformation->intro !!} + @else + {{ trans('checkout::page.trip_details.travel_information_intro') }} + @endif

@@ -69,7 +74,11 @@ class="h-5 w-5 text-gray-600 transition-transform group-open:rotate-180" class="h-5 w-5 text-blue-600 border-gray-300 rounded"> - {{ trans('checkout::page.trip_details.travel_information_confirmation') }} + @if(filled($travelInformation?->checkboxText)) + {!! $travelInformation->checkboxText !!} + @else + {{ trans('checkout::page.trip_details.travel_information_confirmation') }} + @endif diff --git a/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php b/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php index 6a9c8a89..62b35f22 100644 --- a/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php +++ b/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php @@ -4,7 +4,6 @@ use Nezasa\Checkout\Dtos\Checkout\CheckoutParamsDto; use Nezasa\Checkout\Dtos\Planner\RequiredResponses; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\EuPrrlLinkResponseEntity; -use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; use Nezasa\Checkout\Integrations\Nezasa\Requests\Checkout\GetAvailableUpsellItemsRequest; use Nezasa\Checkout\Integrations\Nezasa\Requests\Checkout\GetRequlatoryInformationRequest; @@ -76,8 +75,6 @@ }); it('maps on-request confirmation fields from regulatory information', function (): void { - $expected = new OnRequestResponseEntity; - $response = RegulatoryInformationResponse::from([ 'paymentExplainer' => 'Payments are handled securely.', 'onRequest' => [ @@ -87,11 +84,27 @@ ], ]); - expect($response->onRequest?->confirmationEnabled)->toBeFalse() - ->and($response->onRequest?->confirmationText)->toBe($expected->confirmationText) - ->and($response->onRequest?->remarks)->toBe($expected->remarks) + expect($response->onRequest?->confirmationEnabled)->toBeTrue() + ->and($response->onRequest?->confirmationText)->toBe('I understand this booking is on request.') + ->and($response->onRequest?->remarks)->toBe('

This booking requires manual confirmation.

') ->and($response->onRequest?->getConfirmationKey())->toBe(md5(json_encode([ - 'confirmationText' => $expected->confirmationText, - 'remarks' => $expected->remarks, + 'confirmationText' => 'I understand this booking is on request.', + 'remarks' => '

This booking requires manual confirmation.

', ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))); }); + +it('maps travel information confirmation content from regulatory information', function (): void { + $response = RegulatoryInformationResponse::from([ + 'travelInformation' => [ + 'confirmationEnabled' => true, + 'title' => 'General entry requirements', + 'intro' => '

Please check below Entry and Health Regulations that apply for the chosen destinations.

', + 'checkboxText' => 'I confirm that I read Entry and Health Regulations above', + ], + ]); + + expect($response->travelInformation?->confirmationEnabled)->toBeTrue() + ->and($response->travelInformation?->title)->toBe('General entry requirements') + ->and($response->travelInformation?->intro)->toBe('

Please check below Entry and Health Regulations that apply for the chosen destinations.

') + ->and($response->travelInformation?->checkboxText)->toBe('I confirm that I read Entry and Health Regulations above'); +}); From 492a3532bf903772dc883d7d6e4f42c95521e29d Mon Sep 17 00:00:00 2001 From: Azim Kordpour Date: Thu, 25 Jun 2026 17:07:34 +0200 Subject: [PATCH 4/7] MLZ-4892: Remove placeholder values from `TravelInformationResponseEntity` constructor; clean up initialization logic. --- .../Entities/TravelInformationResponseEntity.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php index 8eaf6c05..01a9d6d3 100644 --- a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php @@ -16,11 +16,5 @@ public function __construct( public ?string $title = null, public ?string $intro = null, public ?string $checkboxText = null, - ) { - // todo: remove this after testing - $this->confirmationEnabled = true; - $this->title = 'Test: Travel Information'; - $this->intro = 'Test: Please confirm your travel information.'; - $this->checkboxText = 'Test: I confirm that the travel information provided is correct.'; - } + ) {} } From 346b1c6d10270fea3afaf7ebcae6d1772e6b9a0a Mon Sep 17 00:00:00 2001 From: Azim Kordpour Date: Thu, 25 Jun 2026 22:43:58 +0200 Subject: [PATCH 5/7] MLZ-4892: Add temporary test defaults to `TravelInformationResponseEntity` constructor; enhance blade with `x-data` for section toggling and checkbox behavior. --- .../Entities/TravelInformationResponseEntity.php | 8 +++++++- .../Views/blades/travel-information-section.blade.php | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php index 01a9d6d3..8eaf6c05 100644 --- a/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php @@ -16,5 +16,11 @@ public function __construct( public ?string $title = null, public ?string $intro = null, public ?string $checkboxText = null, - ) {} + ) { + // todo: remove this after testing + $this->confirmationEnabled = true; + $this->title = 'Test: Travel Information'; + $this->intro = 'Test: Please confirm your travel information.'; + $this->checkboxText = 'Test: I confirm that the travel information provided is correct.'; + } } diff --git a/src/Resources/Views/blades/travel-information-section.blade.php b/src/Resources/Views/blades/travel-information-section.blade.php index 25c0d169..f7b0bec1 100644 --- a/src/Resources/Views/blades/travel-information-section.blade.php +++ b/src/Resources/Views/blades/travel-information-section.blade.php @@ -16,7 +16,7 @@ class="{{ $this->shouldRender() ? '' : 'hidden' }}" onEdit="reopen('{{ Section::TravelInformation->value }}')" > -
+

@if(filled($travelInformation?->intro)) {!! $travelInformation->intro !!} @@ -27,8 +27,11 @@ class="{{ $this->shouldRender() ? '' : 'hidden' }}"

@foreach($sections as $key => $label) -
first) open @endif> - +
+ {{ $label }}