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..c6b42744 --- /dev/null +++ b/src/Actions/TravelInformation/LoadTravelInformationAction.php @@ -0,0 +1,103 @@ +|array $destinationCountries + * @return Collection + */ + public function run(Checkout $checkout, Collection|array $destinationCountries, string $language): Collection + { + $destinations = $this->destinationCodes($destinationCountries); + $nationalities = $this->nationalityCodes($checkout); + + 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], + record: $content->recordForCombination($pair[0], $pair[1]), + )); + } + + /** + * @param Collection|array $destinationCountries + */ + public function confirmationHash(Checkout $checkout, Collection|array $destinationCountries): string + { + return md5(json_encode([ + 'destinations' => $this->destinationCodes($destinationCountries)->sort()->values()->all(), + 'nationalities' => $this->nationalityCodes($checkout)->sort()->values()->all(), + ], JSON_THROW_ON_ERROR)); + } + + /** + * @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(); + } + + /** + * @param Collection|array $destinationCountries + * @return Collection + */ + private function destinationCodes(Collection|array $destinationCountries): Collection + { + return collect($destinationCountries) + ->filter(fn (mixed $country): bool => is_string($country) && trim($country) !== '') + ->map(fn (string $country): string => $this->normalizeCountryCode($country)) + ->unique() + ->values(); + } + + /** + * @return Collection + */ + private function nationalityCodes(Checkout $checkout): Collection + { + return $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(); + } +} diff --git a/src/Dtos/TravelInformation/TravelInformationCombination.php b/src/Dtos/TravelInformation/TravelInformationCombination.php new file mode 100644 index 00000000..321c8126 --- /dev/null +++ b/src/Dtos/TravelInformation/TravelInformationCombination.php @@ -0,0 +1,48 @@ +record instanceof PassolutionRecordResponse && $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/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..01a9d6d3 --- /dev/null +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/TravelInformationResponseEntity.php @@ -0,0 +1,20 @@ +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..73d08008 --- /dev/null +++ b/src/Integrations/Passolution/Dtos/Responses/PassolutionContentResponse.php @@ -0,0 +1,41 @@ + $records + */ + public function __construct( + public Collection $records = new Collection, + ) {} + + /** + * @param array $payload + */ + public static function fromPayload(array $payload): self + { + $records = $payload['records'] ?? []; + + 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() + ); + } + + public function recordForCombination(string $destinationCountryCode, string $nationalityCountryCode): ?PassolutionRecordResponse + { + 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 @@ + $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..3010f1d4 100644 --- a/src/Livewire/PaymentPage.php +++ b/src/Livewire/PaymentPage.php @@ -3,13 +3,16 @@ 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; use Nezasa\Checkout\Actions\Planner\SummarizeItineraryAction; +use Nezasa\Checkout\Actions\TravelInformation\LoadTravelInformationAction; 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; @@ -80,9 +83,35 @@ protected function initializeRequirements(): bool checkout: $this->model ); + if ($this->requiresTravelInformationConfirmation($result->regulatoryInformation) + && ! $this->hasValidTravelInformationConfirmation()) { + $this->redirect(route('traveler-details', $this->getParams()->toArray())); + + return false; + } + 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')); + } + + private function hasValidTravelInformationConfirmation(): bool + { + if (data_get($this->model->data, 'travel_information_confirmed') !== true) { + return false; + } + + $currentHash = resolve(LoadTravelInformationAction::class) + ->confirmationHash($this->model, $this->itinerary->destinationCountries); + + return data_get($this->model->data, 'travel_information_confirmation_hash') === $currentHash; + } + /** * 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..e97e5424 --- /dev/null +++ b/src/Livewire/TravelInformationSection.php @@ -0,0 +1,149 @@ +> + */ + public array $combinations = []; + + public function mount(LoadTravelInformationAction $loadTravelInformationAction): void + { + if ($this->shouldRender()) { + $this->syncTravelInformationConfirmation($loadTravelInformationAction); + } + + 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, + 'travel_information_confirmation_hash' => $value + ? resolve(LoadTravelInformationAction::class)->confirmationHash($this->model, $this->itinerary->destinationCountries) + : null, + ]); + + 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, + 'travel_information_confirmation_hash' => resolve(LoadTravelInformationAction::class) + ->confirmationHash($this->model, $this->itinerary->destinationCountries), + ]); + $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->syncTravelInformationConfirmation($loadTravelInformationAction); + $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->health(), + 'entry' => $combination->entry(), + 'visa' => $combination->visa(), + 'transit_visa' => $combination->transitVisa(), + ]) + ->values() + ->all(); + } + + private function syncTravelInformationConfirmation(LoadTravelInformationAction $loadTravelInformationAction): void + { + $confirmationHash = $loadTravelInformationAction->confirmationHash($this->model, $this->itinerary->destinationCountries); + $storedConfirmationHash = data_get($this->model->data, 'travel_information_confirmation_hash'); + + if ($storedConfirmationHash !== $confirmationHash) { + $this->model->updateData([ + 'travel_information_confirmed' => false, + 'travel_information_confirmation_hash' => null, + 'status.'.Section::TravelInformation->value.'.isCompleted' => false, + ]); + $this->isCompleted = false; + } + + $this->travelInformationConfirmed = (bool) data_get($this->model->refresh()->data, 'travel_information_confirmed', false); + } +} 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'), +]) +@php($travelInformation = $regulatoryInformation->travelInformation) + + +
+

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

+ +
+ @foreach($sections as $key => $label) +
+ + {{ $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/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'); +}); diff --git a/tests/Unit/Livewire/WorkflowSectionsTest.php b/tests/Unit/Livewire/WorkflowSectionsTest.php index ab0335a7..612622ce 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,179 @@ 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([ + 'records' => [ + [ + 'destination' => 'DE', + 'nationality' => 'IR', + 'title' => 'Destination Germany / Nationality Iran', + '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.'], + ], + ], + ]), + ]); + + $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(data_get($checkout->data, 'travel_information_confirmation_hash'))->not->toBeNull() + ->and($component->isCompleted)->toBeTrue() + ->and($component->isExpanded)->toBeFalse(); +}); + +it('resets travel information confirmation when destinations or nationalities change', function (): void { + config()->set('checkout.integrations.passolution.active', true); + config()->set('checkout.integrations.passolution.token', 'test-token'); + + $checkout = livewireWorkflowCheckout([ + 'paxInfo' => [ + [ + ['nationalityCountryCode' => 'EG'], + ], + ], + 'travel_information_confirmed' => true, + 'travel_information_confirmation_hash' => 'stale-hash', + 'status' => array_replace_recursive(Checkout::buildSectionStatus(), [ + Section::TravelInformation->value => ['isCompleted' => true], + ]), + ]); + $component = new TravelInformationSection; + primeBaseCheckoutComponent($component, $checkout); + $component->itinerary = livewireWorkflowItinerary(); + $component->regulatoryInformation = new RegulatoryInformationResponse( + travelInformation: new TravelInformationResponseEntity(confirmationEnabled: true) + ); + $component->isCompleted = true; + + $component->mount(new LoadTravelInformationAction); + $checkout->refresh(); + + expect($component->travelInformationConfirmed)->toBeFalse() + ->and($component->isCompleted)->toBeFalse() + ->and(data_get($checkout->data, 'travel_information_confirmed'))->toBeFalse() + ->and(data_get($checkout->data, 'travel_information_confirmation_hash'))->toBeNull() + ->and(data_get($checkout->data, 'status.'.Section::TravelInformation->value.'.isCompleted'))->toBeFalse(); +}); + +it('resets stale travel information confirmation when traveller nationality changes before continuing again', function (): void { + config()->set('checkout.integrations.passolution.active', true); + config()->set('checkout.integrations.passolution.token', 'test-token'); + + MockClient::global([ + GetContentRequest::class => MockResponse::make([ + 'records' => [], + ]), + ]); + + $checkout = livewireWorkflowCheckout([ + 'paxInfo' => [ + [ + ['nationalityCountryCode' => 'EG'], + ], + ], + ]); + $component = new TravelInformationSection; + primeBaseCheckoutComponent($component, $checkout); + $component->itinerary = livewireWorkflowItinerary(); + $component->regulatoryInformation = new RegulatoryInformationResponse( + travelInformation: new TravelInformationResponseEntity(confirmationEnabled: true) + ); + + $component->toggleTravelInformationConfirmation(true); + $component->next(); + $checkout->refresh(); + + expect(data_get($checkout->data, 'travel_information_confirmed'))->toBeTrue(); + + $checkout->updateData([ + 'paxInfo.0.0.nationalityCountryCode' => 'FR', + 'status.'.Section::TravelInformation->value.'.isCompleted' => false, + ]); + $component->isCompleted = true; + $component->travelInformationConfirmed = true; + + $component->listen(new LoadTravelInformationAction); + $checkout->refresh(); + + expect($component->travelInformationConfirmed)->toBeFalse() + ->and($component->isCompleted)->toBeFalse() + ->and(data_get($checkout->data, 'travel_information_confirmed'))->toBeFalse() + ->and(data_get($checkout->data, 'travel_information_confirmation_hash'))->toBeNull(); +}); + it('loads EU-PRRL terms acceptance only for the current content hash', function (): void { $acceptedEuPrrl = new EuPrrlResponseEntity( generalTermsConfirmationEnabled: true,