diff --git a/controllers/front/AbstractOpcJsonFrontController.php b/controllers/front/AbstractOpcJsonFrontController.php index 058a733..9bc65c9 100644 --- a/controllers/front/AbstractOpcJsonFrontController.php +++ b/controllers/front/AbstractOpcJsonFrontController.php @@ -1,5 +1,8 @@ */ - abstract protected function handleOpcRequest(): array; + protected function handleOpcRequest(): array + { + if (!$this->isOpcAvailable()) { + return $this->buildTechnicalErrorResponse(); + } + + try { + return $this->handleAvailableOpcRequest(); + } catch (Throwable $exception) { + return $this->handleRuntimeException($exception); + } + } + + /** + * @return array + */ + abstract protected function handleAvailableOpcRequest(): array; protected function isOpcAvailable(): bool { - return $this->module instanceof Ps_Onepagecheckout - && $this->module->isOnePageCheckoutEnabled(); + assert($this->module instanceof Ps_Onepagecheckout); + + return $this->module->isOnePageCheckoutEnabled(); } /** @@ -32,7 +52,7 @@ protected function buildTechnicalErrorResponse(): array 'success' => false, 'errors' => [ '' => [ - $this->trans('One-page checkout is currently unavailable.', [], 'Shop.Notifications.Error'), + $this->trans('One-page checkout is currently unavailable.', [], ModuleTranslation::SHOP_DOMAIN), ], ], ]; @@ -46,6 +66,29 @@ protected function getTechnicalErrorResponseExtra(): array return []; } + /** + * @return array + */ + protected function handleRuntimeException(Throwable $exception): array + { + PrestaShopLogger::addLog( + sprintf('ps_onepagecheckout runtime exception: %s', $exception->getMessage()), + 3, + null, + 'Module', + (int) $this->module->id, + true + ); + + Analytics::trackOpcCriticalError( + 'unknown', + (bool) Configuration::get('PS_GUEST_CHECKOUT_ENABLED') ? 'yes' : 'no', + (string) $this->module->version + ); + + return $this->buildTechnicalErrorResponse(); + } + /** * @param array $response */ diff --git a/controllers/front/addresseslist.php b/controllers/front/addresseslist.php index 18abc5e..57b828f 100644 --- a/controllers/front/addresseslist.php +++ b/controllers/front/addresseslist.php @@ -14,53 +14,37 @@ class Ps_OnepagecheckoutAddressesListModuleFrontController extends Ps_Onepageche /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); + $handler = new OnePageCheckoutAddressesListHandler( + $this->context, + $this->module->getTranslator(), + new CheckoutCustomerContextResolver($this->context) + ); + $response = $handler->handle(); + if (empty($response['success'])) { + return $response; } - try { - $handler = new OnePageCheckoutAddressesListHandler( - $this->context, - new CheckoutCustomerContextResolver($this->context) - ); - $response = $handler->handle(); - if (empty($response['success'])) { - return $response; - } - - return [ - 'success' => true, - 'address_count' => (int) ($response['address_count'] ?? 0), - 'delivery_html' => $this->render( - 'checkout/_partials/one-page-checkout/address-list', - [ - 'customer' => $response['customer'] ?? [], - 'prefix' => '', - 'selected_address' => (int) ($response['selected_delivery_address'] ?? 0), - ] - ), - 'billing_html' => $this->render( - 'checkout/_partials/one-page-checkout/address-list', - [ - 'customer' => $response['customer'] ?? [], - 'prefix' => 'invoice_', - 'selected_address' => (int) ($response['selected_invoice_address'] ?? 0), - ] - ), - ]; - } catch (Throwable $exception) { - PrestaShopLogger::addLog( - sprintf('ps_onepagecheckout addressesList runtime exception: %s', $exception->getMessage()), - 3, - null, - 'Module', - (int) $this->module->id, - true - ); - - return $this->buildTechnicalErrorResponse(); - } + return [ + 'success' => true, + 'address_count' => (int) ($response['address_count'] ?? 0), + 'delivery_html' => $this->render( + 'checkout/_partials/one-page-checkout/address-list', + [ + 'customer' => $response['customer'] ?? [], + 'prefix' => '', + 'selected_address' => (int) ($response['selected_delivery_address'] ?? 0), + ] + ), + 'billing_html' => $this->render( + 'checkout/_partials/one-page-checkout/address-list', + [ + 'customer' => $response['customer'] ?? [], + 'prefix' => 'invoice_', + 'selected_address' => (int) ($response['selected_invoice_address'] ?? 0), + ] + ), + ]; } } diff --git a/controllers/front/addressform.php b/controllers/front/addressform.php index 86f89c1..430ad5e 100644 --- a/controllers/front/addressform.php +++ b/controllers/front/addressform.php @@ -4,7 +4,6 @@ * AJAX endpoint for module-owned OPC address form refresh. */ -use PrestaShop\Module\PsOnePageCheckout\Analytics\Analytics; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutAddressFormHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; @@ -16,43 +15,18 @@ class Ps_OnepagecheckoutAddressFormModuleFrontController extends Ps_Onepagecheck /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - - try { - $opcFormFactory = $this->getOpcFormFactory(); - $handler = $this->createAddressFormHandler($opcFormFactory); - $templateVariables = $handler->getTemplateVariables(Tools::getAllValues()); - - return [ - 'addresses_section' => $this->render( - 'checkout/_partials/one-page-checkout/addresses-section', - $templateVariables - ), - ]; - } catch (Throwable $exception) { - PrestaShopLogger::addLog( - sprintf('ps_onepagecheckout addressForm runtime exception: %s', $exception->getMessage()), - 3, - null, - 'Module', - (int) $this->module->id, - true - ); - - if ($this->module instanceof Ps_Onepagecheckout) { - Analytics::trackOpcCriticalError( - 'unknown', - (bool) Configuration::get('PS_GUEST_CHECKOUT_ENABLED') ? 'yes' : 'no', - (string) $this->module->version - ); - } - - return $this->buildTechnicalErrorResponse(); - } + $opcFormFactory = $this->getOpcFormFactory(); + $handler = $this->createAddressFormHandler($opcFormFactory); + $templateVariables = $handler->getTemplateVariables(Tools::getAllValues()); + + return [ + 'addresses_section' => $this->render( + 'checkout/_partials/one-page-checkout/addresses-section', + $templateVariables + ), + ]; } protected function getOpcFormFactory(): OnePageCheckoutFormFactory diff --git a/controllers/front/carriers.php b/controllers/front/carriers.php index c5123fa..9af3d38 100644 --- a/controllers/front/carriers.php +++ b/controllers/front/carriers.php @@ -13,12 +13,8 @@ class Ps_OnepagecheckoutCarriersModuleFrontController extends Ps_Onepagecheckout /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $handler = new OnePageCheckoutCarriersHandler($this->context, $this->module->getTranslator()); $response = $handler->handle(Tools::getAllValues()); diff --git a/controllers/front/carttotals.php b/controllers/front/carttotals.php index b763f04..1a3408f 100644 --- a/controllers/front/carttotals.php +++ b/controllers/front/carttotals.php @@ -13,12 +13,8 @@ class Ps_OnepagecheckoutCartTotalsModuleFrontController extends Ps_Onepagechecko /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $cartPresenterHelper = new CartPresenterHelper($this->context); $cartPreview = $cartPresenterHelper->presentCart(); diff --git a/controllers/front/deleteaddress.php b/controllers/front/deleteaddress.php index af7b70f..45ec34a 100644 --- a/controllers/front/deleteaddress.php +++ b/controllers/front/deleteaddress.php @@ -14,12 +14,8 @@ class Ps_OnepagecheckoutDeleteAddressModuleFrontController extends Ps_Onepageche /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $handler = new OnePageCheckoutDeleteAddressHandler( $this->context, $this->module->getTranslator(), diff --git a/controllers/front/giftwrapping.php b/controllers/front/giftwrapping.php new file mode 100644 index 0000000..54b987e --- /dev/null +++ b/controllers/front/giftwrapping.php @@ -0,0 +1,33 @@ + + */ + protected function handleAvailableOpcRequest(): array + { + $handler = new OnePageCheckoutGiftWrappingHandler($this->context, $this->module->getTranslator()); + $result = $handler->handle(Tools::getAllValues()); + + if (empty($result['success']) || !isset($result['cart'])) { + return $result; + } + + return [ + 'success' => true, + 'preview' => $this->render( + 'checkout/_partials/cart-summary', + [ + 'cart' => $result['cart'], + 'static_token' => Tools::getToken(false), + ] + ), + 'totals' => $result['totals'], + ]; + } +} diff --git a/controllers/front/guestinit.php b/controllers/front/guestinit.php index 9a45d45..234bd6e 100644 --- a/controllers/front/guestinit.php +++ b/controllers/front/guestinit.php @@ -4,7 +4,6 @@ * AJAX endpoint for module-owned OPC guest initialization. */ -use PrestaShop\Module\PsOnePageCheckout\Analytics\Analytics; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutGuestInitHandler; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutFormFactory; @@ -15,37 +14,12 @@ class Ps_OnepagecheckoutGuestInitModuleFrontController extends Ps_Onepagecheckou /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } + $opcFormFactory = $this->getOpcFormFactory(); + $handler = $this->createGuestInitHandler($opcFormFactory); - try { - $opcFormFactory = $this->getOpcFormFactory(); - $handler = $this->createGuestInitHandler($opcFormFactory); - - return $handler->handle(Tools::getAllValues()); - } catch (Throwable $exception) { - PrestaShopLogger::addLog( - sprintf('ps_onepagecheckout guestInit runtime exception: %s', $exception->getMessage()), - 3, - null, - 'Module', - (int) $this->module->id, - true - ); - - if ($this->module instanceof Ps_Onepagecheckout) { - Analytics::trackOpcCriticalError( - 'unknown', - (bool) Configuration::get('PS_GUEST_CHECKOUT_ENABLED') ? 'yes' : 'no', - (string) $this->module->version - ); - } - - return $this->buildTechnicalErrorResponse(); - } + return $handler->handle(Tools::getAllValues()); } protected function getOpcFormFactory(): OnePageCheckoutFormFactory diff --git a/controllers/front/opcsubmit.php b/controllers/front/opcsubmit.php index 9404f71..1db5cf9 100644 --- a/controllers/front/opcsubmit.php +++ b/controllers/front/opcsubmit.php @@ -1,6 +1,5 @@ */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } + $requestParameters = Tools::getAllValues(); if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { header('HTTP/1.1 405 Method Not Allowed'); @@ -31,38 +28,13 @@ protected function handleOpcRequest(): array ]; } - try { - $result = $this->createSubmitHandler()->handle(Tools::getAllValues()); - if (($result['success'] ?? false) === true && $this->module instanceof Ps_Onepagecheckout) { - Analytics::trackCheckoutCompleted( - (bool) Configuration::get('PS_GUEST_CHECKOUT_ENABLED') ? 'yes' : 'no', - trim((string) (Tools::getValue('paymentMethod') ?? '')), - (string) $this->module->version - ); - } - - return $result; - } catch (Throwable $exception) { - PrestaShopLogger::addLog( - sprintf('ps_onepagecheckout opcSubmit runtime exception: %s', $exception->getMessage()), - 3, - null, - 'Module', - (int) $this->module->id, - true - ); - - return $this->buildTechnicalErrorResponse(); - } + return $this->createSubmitHandler()->handle($requestParameters, $this->buildTechnicalErrorResponse()); } protected function createSubmitHandler(): OnePageCheckoutSubmitHandler { + /** @var Ps_Onepagecheckout $module */ $module = $this->module; - if (!$module instanceof Ps_Onepagecheckout) { - throw new LogicException('ps_onepagecheckout module is not available.'); - } - $translator = $module->getTranslator(); $checkoutSessionFactory = new CheckoutSessionFactory($this->context, $translator); $formFactory = new OnePageCheckoutFormFactory($this->context, $module); @@ -77,7 +49,7 @@ protected function createSubmitHandler(): OnePageCheckoutSubmitHandler new PaymentOptionsFinder(), new ConditionsToApproveFinder($this->context, $translator) ), - new OnePageCheckoutSubmitValidationStateStorage($this->context) + $this->createSubmitValidationStateStorage() ); } @@ -98,4 +70,9 @@ private function getCheckoutUrl(): string ? (string) $this->context->link->getPageLink('order') : ''; } + + protected function createSubmitValidationStateStorage(): OnePageCheckoutSubmitValidationStateStorage + { + return new OnePageCheckoutSubmitValidationStateStorage($this->context); + } } diff --git a/controllers/front/paymentmethods.php b/controllers/front/paymentmethods.php index 4cb466e..dec2616 100644 --- a/controllers/front/paymentmethods.php +++ b/controllers/front/paymentmethods.php @@ -13,41 +13,24 @@ class Ps_OnepagecheckoutPaymentMethodsModuleFrontController extends Ps_Onepagech /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - - try { - $handler = new OnePageCheckoutPaymentMethodsHandler($this->context); - $response = $handler->handle(Tools::getAllValues()); - - if (!empty($response['success'])) { - $response['payment_html'] = $this->render( - 'checkout/_partials/one-page-checkout/payment-methods', - [ - 'payment_options' => $response['payment_options'] ?? [], - 'is_free' => $response['is_free'] ?? false, - 'selected_payment_module' => $response['selected_payment_module'] ?? '', - 'selected_payment_selection_key' => $response['selected_payment_selection_key'] ?? '', - ] - ); - unset($response['payment_options']); - } - - return $response; - } catch (Throwable $exception) { - PrestaShopLogger::addLog( - sprintf('ps_onepagecheckout paymentMethods runtime exception: %s', $exception->getMessage()), - 3, - null, - 'Module', - (int) $this->module->id, - true + $handler = new OnePageCheckoutPaymentMethodsHandler($this->context); + $response = $handler->handle(Tools::getAllValues()); + + if (!empty($response['success'])) { + $response['payment_html'] = $this->render( + 'checkout/_partials/one-page-checkout/payment-methods', + [ + 'payment_options' => $response['payment_options'] ?? [], + 'is_free' => $response['is_free'] ?? false, + 'selected_payment_module' => $response['selected_payment_module'] ?? '', + 'selected_payment_selection_key' => $response['selected_payment_selection_key'] ?? '', + ] ); - - return $this->buildTechnicalErrorResponse(); + unset($response['payment_options']); } + + return $response; } } diff --git a/controllers/front/saveaddress.php b/controllers/front/saveaddress.php index 065269e..ab2c487 100644 --- a/controllers/front/saveaddress.php +++ b/controllers/front/saveaddress.php @@ -14,12 +14,8 @@ class Ps_OnepagecheckoutSaveAddressModuleFrontController extends Ps_Onepagecheck /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $handler = new OnePageCheckoutSaveAddressHandler( $this->context, $this->module->getTranslator(), diff --git a/controllers/front/selectcarrier.php b/controllers/front/selectcarrier.php index cc46882..d9c229f 100644 --- a/controllers/front/selectcarrier.php +++ b/controllers/front/selectcarrier.php @@ -13,12 +13,8 @@ class Ps_OnepagecheckoutSelectCarrierModuleFrontController extends Ps_Onepageche /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $handler = new OnePageCheckoutSelectCarrierHandler($this->context, $this->module->getTranslator()); $response = $handler->handle(Tools::getAllValues()); diff --git a/controllers/front/selectpayment.php b/controllers/front/selectpayment.php index 1b6373b..a4062e3 100644 --- a/controllers/front/selectpayment.php +++ b/controllers/front/selectpayment.php @@ -13,13 +13,9 @@ class Ps_OnepagecheckoutSelectPaymentModuleFrontController extends Ps_Onepageche /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - - $handler = new OnePageCheckoutSelectPaymentHandler($this->context); + $handler = new OnePageCheckoutSelectPaymentHandler($this->context, $this->module->getTranslator()); return $handler->handle(Tools::getAllValues()); } diff --git a/controllers/front/states.php b/controllers/front/states.php index 21ac8ce..0e44e6d 100644 --- a/controllers/front/states.php +++ b/controllers/front/states.php @@ -13,12 +13,8 @@ class Ps_OnepagecheckoutStatesModuleFrontController extends Ps_OnepagecheckoutAb /** * @return array */ - protected function handleOpcRequest(): array + protected function handleAvailableOpcRequest(): array { - if (!$this->isOpcAvailable()) { - return $this->buildTechnicalErrorResponse(); - } - $handler = new OnePageCheckoutStatesHandler(); return $handler->handle(Tools::getAllValues()); diff --git a/docs/RULES.md b/docs/RULES.md index 2651ef8..ae880b3 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -53,6 +53,8 @@ Both entry points must render the same module-owned configuration flow (no redir 1. Each migration lot must be implemented with a story/test pair and incremental automated verification. 2. Every lot must ship unit tests for local logic and integration tests for observable behavior. 3. After JS changes, rebuild `views/public/*` and verify the runtime contracts through tests. +4. Automated E2E investigation must start with incremental scope (`test:file`, `test:lot`, or `tests/e2e` `test:all:incremental`) before widening execution. +5. When a human explicitly asks for a full rerun to inventory failures, use exhaustive execution so `serial` suites cannot mask later failures (`tests/e2e` `npm run test:all` or `npm run test:all:exhaustive`). ## Delivery checklist diff --git a/ps_onepagecheckout.php b/ps_onepagecheckout.php index 6e9f8d3..bf60ad2 100644 --- a/ps_onepagecheckout.php +++ b/ps_onepagecheckout.php @@ -16,6 +16,7 @@ use PrestaShop\Module\PsOnePageCheckout\Checkout\OnePageCheckoutAvailability; use PrestaShop\Module\PsOnePageCheckout\Checkout\OnePageCheckoutProcessProvider; use PrestaShop\Module\PsOnePageCheckout\Form\BackOfficeConfigurationForm; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use PrestaShop\PrestaShop\Adapter\Order\Checkout\CheckoutProcessProviderInterface; class Ps_Onepagecheckout extends Module @@ -34,7 +35,7 @@ public function __construct() $tabNames = []; foreach (Language::getLanguages(true) as $lang) { - $tabNames[$lang['locale']] = $this->trans('Checkout', [], 'Modules.Psonepagecheckout.Admin', $lang['locale']); + $tabNames[$lang['locale']] = $this->trans('Checkout', [], ModuleTranslation::ADMIN_DOMAIN, $lang['locale']); } $this->tabs = [ [ @@ -43,17 +44,17 @@ public function __construct() 'name' => $tabNames, 'parent_class_name' => 'AdminParentThemes', 'wording' => 'Checkout', - 'wording_domain' => 'Modules.Psonepagecheckout.Admin', + 'wording_domain' => ModuleTranslation::ADMIN_DOMAIN, ], ]; parent::__construct(); - $this->displayName = $this->trans('One-page checkout', [], 'Modules.Psonepagecheckout.Admin'); + $this->displayName = $this->trans('One-page checkout', [], ModuleTranslation::ADMIN_DOMAIN); $this->description = $this->trans( 'Native one-page checkout.', [], - 'Modules.Psonepagecheckout.Admin' + ModuleTranslation::ADMIN_DOMAIN ); $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => _PS_VERSION_]; $this->controllers = [ @@ -270,6 +271,15 @@ public function hookActionFrontControllerSetMedia(): void null, true ), + 'giftWrapping' => $this->context->link->getModuleLink( + $this->name, + 'giftwrapping', + ['ajax' => 1, 'action' => 'opcGiftWrapping'], + null, + null, + null, + true + ), 'cartTotals' => $this->context->link->getModuleLink( $this->name, 'carttotals', @@ -280,6 +290,25 @@ public function hookActionFrontControllerSetMedia(): void true ), ], + 'messages' => [ + 'missingGuestInitUrl' => $this->trans('Unable to initialize checkout customer.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingAddressFormUrl' => $this->trans('Unable to refresh addresses.', [], ModuleTranslation::SHOP_DOMAIN), + 'loadCarriersFailed' => $this->trans('Unable to load delivery methods.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingCarrierSelectionPayload' => $this->trans('Missing delivery option.', [], ModuleTranslation::SHOP_DOMAIN), + 'selectCarrierFailed' => $this->trans('Unable to select the delivery method.', [], ModuleTranslation::SHOP_DOMAIN), + 'loadPaymentMethodsFailed' => $this->trans('Unable to load payment methods.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingPaymentSelectionPayload' => $this->trans('Missing payment selection payload.', [], ModuleTranslation::SHOP_DOMAIN), + 'selectPaymentFailed' => $this->trans('Unable to select the payment method.', [], ModuleTranslation::SHOP_DOMAIN), + 'statesLoadFailed' => $this->trans('Unable to load states.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingSaveAddressUrl' => $this->trans('Unable to save address.', [], ModuleTranslation::SHOP_DOMAIN), + 'saveAddressFailed' => $this->trans('Unable to save address.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingDeleteAddressUrl' => $this->trans('Unable to delete address.', [], ModuleTranslation::SHOP_DOMAIN), + 'deleteAddressFailed' => $this->trans('Unable to delete address.', [], ModuleTranslation::SHOP_DOMAIN), + 'refreshAddressesFailed' => $this->trans('Unable to refresh addresses.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingPaymentForm' => $this->trans('Unable to initialize the selected payment method.', [], ModuleTranslation::SHOP_DOMAIN), + 'missingSubmitUrl' => $this->trans('Unable to submit checkout.', [], ModuleTranslation::SHOP_DOMAIN), + 'submitFailed' => $this->trans('Unable to submit checkout.', [], ModuleTranslation::SHOP_DOMAIN), + ], ]; $this->addOpcJavascriptDefinition([ @@ -346,6 +375,7 @@ protected function registerOpcJavascriptAssets(): void ['module-ps-onepagecheckout-select-carrier', 'views/public/opc-carrier-select.bundle.js', 154], ['module-ps-onepagecheckout-payment-methods', 'views/public/opc-payment-list.bundle.js', 155], ['module-ps-onepagecheckout-select-payment', 'views/public/opc-payment-select.bundle.js', 156], + ['module-ps-onepagecheckout-gift-wrapping', 'views/public/opc-gift-wrapping.bundle.js', 157], ] as [$id, $path, $priority]) { $this->context->controller->registerJavascript( $id, diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php index d79edfc..0d6c95e 100644 --- a/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressFormHandler.php @@ -58,10 +58,6 @@ public function getTemplateVariables(array $requestParameters): array continue; } - if ($name === 'invoice_id_country' && $useSameAddress) { - continue; - } - if (in_array($name, ['id_address_delivery', 'id_address_invoice'], true) && (int) $requestParameters[$name] <= 0) { continue; } diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php index 1d45ea1..bbbc04f 100644 --- a/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php +++ b/src/Checkout/Ajax/Address/OnePageCheckoutAddressesListHandler.php @@ -2,18 +2,24 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; +use Symfony\Contracts\Translation\TranslatorInterface; + class OnePageCheckoutAddressesListHandler { private \Context $context; + private TranslatorInterface $translator; private CheckoutCustomerContextResolver $customerResolver; private CheckoutCustomerTemplateBuilder $customerTemplateBuilder; public function __construct( \Context $context, + TranslatorInterface $translator, CheckoutCustomerContextResolver $customerResolver, ?CheckoutCustomerTemplateBuilder $customerTemplateBuilder = null, ) { $this->context = $context; + $this->translator = $translator; $this->customerResolver = $customerResolver; $this->customerTemplateBuilder = $customerTemplateBuilder ?? new CheckoutCustomerTemplateBuilder( $context, @@ -28,7 +34,9 @@ public function handle(): array { $customer = $this->customerResolver->resolve(); if (!$customer instanceof \Customer) { - return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve checkout customer.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $customerTemplate = $this->customerTemplateBuilder->build(); diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php index d398edf..ee64409 100644 --- a/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php +++ b/src/Checkout/Ajax/Address/OnePageCheckoutDeleteAddressHandler.php @@ -2,6 +2,7 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutDeleteAddressHandler @@ -32,12 +33,16 @@ public function handle(array $requestParameters = []): array { $customer = $this->customerResolver->resolve(); if (!$customer instanceof \Customer) { - return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve checkout customer.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $address = $this->loadOwnedAddress($customer, (int) ($requestParameters['id_address'] ?? 0)); if (!$address instanceof \Address) { - return CheckoutAjaxResponse::error('Unable to load the requested address.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to load the requested address.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $addressId = (int) $address->id; @@ -48,7 +53,9 @@ public function handle(array $requestParameters = []): array && (int) $this->context->cart->id_address_invoice === $addressId; if (!$this->buildAddressPersister($customer)->delete($address, \Tools::getToken(true, $this->context))) { - return CheckoutAjaxResponse::error('Unable to delete address.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to delete address.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $remainingAddresses = $customer->getAddresses((int) $this->context->language->id); @@ -68,11 +75,7 @@ public function handle(array $requestParameters = []): array return [ 'success' => true, 'id_address' => $addressId, - 'message' => $this->translator->trans( - 'Address successfully deleted.', - [], - 'Shop.Notifications.Success' - ), + 'message' => $this->translator->trans('Address successfully deleted.', [], ModuleTranslation::SHOP_DOMAIN), ]; } diff --git a/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php b/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php index bad8047..e7c3cf5 100644 --- a/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php +++ b/src/Checkout/Ajax/Address/OnePageCheckoutSaveAddressHandler.php @@ -4,6 +4,7 @@ use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutAddressForm; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutAddressFormatter; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutSaveAddressHandler @@ -31,7 +32,9 @@ public function handle(array $requestParameters = []): array { $customerId = $this->customerResolver->resolveId(); if ($customerId <= 0) { - return CheckoutAjaxResponse::error('Unable to resolve checkout customer.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve checkout customer.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $addressType = (string) ($requestParameters['address_type'] ?? 'delivery'); @@ -40,7 +43,9 @@ public function handle(array $requestParameters = []): array $address = $addressId > 0 ? new \Address($addressId, (int) $this->context->language->id) : new \Address(); if ($addressId > 0 && (!\Validate::isLoadedObject($address) || (int) $address->id_customer !== $customerId)) { - return CheckoutAjaxResponse::error('Unable to load the requested address.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to load the requested address.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $addressForm = $this->createAddressForm(); @@ -52,7 +57,9 @@ public function handle(array $requestParameters = []): array $this->hydrateAddressFromForm($address, $addressForm, $addressType, $customerId); if (!$this->buildAddressPersister($customerId)->save($address, \Tools::getToken(true, $this->context))) { - return CheckoutAjaxResponse::error('Unable to save address.'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to save address.', [], ModuleTranslation::SHOP_DOMAIN) + ); } if (\Validate::isLoadedObject($this->context->cart)) { @@ -71,6 +78,8 @@ public function handle(array $requestParameters = []): array return [ 'success' => true, + 'id_address' => (int) $address->id, + 'address_type' => $addressType, ]; } @@ -115,8 +124,8 @@ private function hydrateAddressFromForm( $address->id_customer = $customerId; $address->alias = trim((string) ($address->alias ?: ($addressType === 'invoice' - ? $this->translator->trans('Invoice address', [], 'Shop.Theme.Checkout') - : $this->translator->trans('My Address', [], 'Shop.Theme.Checkout')))); + ? $this->translator->trans('Invoice address', [], ModuleTranslation::SHOP_DOMAIN) + : $this->translator->trans('My Address', [], ModuleTranslation::SHOP_DOMAIN)))); $address->id_country = (int) $address->id_country; $address->id_state = (int) ($address->id_state ?: 0); \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$address]); diff --git a/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php b/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php index 6fe5c80..12f620a 100644 --- a/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php +++ b/src/Checkout/Ajax/Carrier/OnePageCheckoutCarriersHandler.php @@ -2,6 +2,7 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutCarriersHandler @@ -12,6 +13,7 @@ class OnePageCheckoutCarriersHandler private CheckoutSessionFactory $checkoutSessionFactory; private CartPresenterHelper $cartPresenterHelper; private TempAddressCarrierSelectionStorage $tempCarrierSelectionStorage; + private TempAddressStorage $tempAddressStorage; public function __construct( \Context $context, @@ -21,6 +23,7 @@ public function __construct( ?CheckoutSessionFactory $checkoutSessionFactory = null, ?CartPresenterHelper $cartPresenterHelper = null, ?TempAddressCarrierSelectionStorage $tempCarrierSelectionStorage = null, + ?TempAddressStorage $tempAddressStorage = null, ) { $this->context = $context; $this->translator = $translator; @@ -28,6 +31,7 @@ public function __construct( $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator, $deliveryOptionsFinder); $this->cartPresenterHelper = $cartPresenterHelper ?? new CartPresenterHelper($context); $this->tempCarrierSelectionStorage = $tempCarrierSelectionStorage ?? new TempAddressCarrierSelectionStorage($context); + $this->tempAddressStorage = $tempAddressStorage ?? new TempAddressStorage($context); } /** @@ -50,7 +54,7 @@ public function handle(array $requestParameters = []): array $requestedAddressId = (int) $requestParameters['id_address_delivery']; if (!$this->isOwnedCheckoutAddress($requestedAddressId)) { return CheckoutAjaxResponse::error( - $this->translator->trans('Invalid delivery address.', [], 'Shop.Notifications.Error'), + $this->translator->trans('Invalid delivery address.', [], ModuleTranslation::SHOP_DOMAIN), 'id_address_delivery' ); } @@ -58,8 +62,12 @@ public function handle(array $requestParameters = []): array $this->context->cart->id_address_delivery = $requestedAddressId; $this->context->cart->save(); $this->tempCarrierSelectionStorage->clear(); + $this->tempAddressStorage->clear(); } else { $tempAddressId = $tempAddress->createFromRequest($requestParameters); + if ($tempAddressId > 0 && $originalAddressId <= 0) { + $this->tempAddressStorage->saveFromRequest($requestParameters); + } } if ((int) $this->context->cart->id_address_delivery <= 0) { @@ -96,6 +104,7 @@ public function handle(array $requestParameters = []): array $selectedDeliveryOption = $persistedTempOption; } else { $this->tempCarrierSelectionStorage->clear(); + $this->tempAddressStorage->clear(); } } diff --git a/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php b/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php index 70511e7..c6ae7a3 100644 --- a/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php +++ b/src/Checkout/Ajax/Carrier/OnePageCheckoutSelectCarrierHandler.php @@ -2,6 +2,7 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutSelectCarrierHandler @@ -11,6 +12,7 @@ class OnePageCheckoutSelectCarrierHandler private CheckoutSessionFactory $checkoutSessionFactory; private CartPresenterHelper $cartPresenterHelper; private TempAddressCarrierSelectionStorage $tempCarrierSelectionStorage; + private TempAddressStorage $tempAddressStorage; public function __construct( \Context $context, @@ -19,12 +21,14 @@ public function __construct( ?CheckoutSessionFactory $checkoutSessionFactory = null, ?CartPresenterHelper $cartPresenterHelper = null, ?TempAddressCarrierSelectionStorage $tempCarrierSelectionStorage = null, + ?TempAddressStorage $tempAddressStorage = null, ) { $this->context = $context; $this->translator = $translator; $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator, $deliveryOptionsFinder); $this->cartPresenterHelper = $cartPresenterHelper ?? new CartPresenterHelper($context); $this->tempCarrierSelectionStorage = $tempCarrierSelectionStorage ?? new TempAddressCarrierSelectionStorage($context); + $this->tempAddressStorage = $tempAddressStorage ?? new TempAddressStorage($context); } /** @@ -37,14 +41,14 @@ public function handle(array $requestParameters = []): array $deliveryOption = (string) ($requestParameters['delivery_option'] ?? ''); if ($deliveryOption === '') { return CheckoutAjaxResponse::error( - $this->translator->trans('Missing delivery option.', [], 'Shop.Notifications.Error'), + $this->translator->trans('Missing delivery option.', [], ModuleTranslation::SHOP_DOMAIN), 'delivery_option' ); } if (!\Validate::isLoadedObject($this->context->cart)) { return CheckoutAjaxResponse::error( - $this->translator->trans('Unable to resolve the current cart.', [], 'Shop.Notifications.Error') + $this->translator->trans('Unable to resolve the current cart.', [], ModuleTranslation::SHOP_DOMAIN) ); } @@ -58,12 +62,12 @@ public function handle(array $requestParameters = []): array if ($deliveryAddressId <= 0) { return CheckoutAjaxResponse::error( - $this->translator->trans('Unable to resolve the current delivery address.', [], 'Shop.Notifications.Error') + $this->translator->trans('Unable to resolve the current delivery address.', [], ModuleTranslation::SHOP_DOMAIN) ); } $this->persistCarrierSelection($deliveryAddressId, $deliveryOption); - $this->persistTemporaryCarrierSelection($deliveryOption, $tempAddressId > 0 && $originalAddressId <= 0); + $this->persistTemporaryCarrierSelection($deliveryOption, $tempAddressId > 0 && $originalAddressId <= 0, $requestParameters); $cartPreview = $this->cartPresenterHelper->presentCart(); @@ -88,14 +92,16 @@ private function persistCarrierSelection(int $deliveryAddressId, string $deliver ]); } - private function persistTemporaryCarrierSelection(string $deliveryOption, bool $shouldPersist): void + private function persistTemporaryCarrierSelection(string $deliveryOption, bool $shouldPersist, array $requestParameters = []): void { if ($shouldPersist) { $this->tempCarrierSelectionStorage->save($deliveryOption); + $this->tempAddressStorage->saveFromRequest($requestParameters); return; } $this->tempCarrierSelectionStorage->clear(); + $this->tempAddressStorage->clear(); } } diff --git a/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php b/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php index 832da11..755480b 100644 --- a/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php +++ b/src/Checkout/Ajax/Customer/OnePageCheckoutGuestInitHandler.php @@ -14,6 +14,7 @@ use Db; use PrestaShop\Module\PsOnePageCheckout\Checkout\ExistingCustomerState; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use PrestaShop\PrestaShop\Core\Util\InternationalizedDomainNameConverter; use Symfony\Contracts\Translation\TranslatorInterface; @@ -119,11 +120,7 @@ public function handle(array $requestParameters): array if (!$this->isOnePageCheckoutEnabled) { return $this->errorResponse( self::ERROR_FIELD_GLOBAL, - $this->translator->trans( - 'One-page checkout is not enabled.', - [], - 'Shop.Notifications.Error' - ) + $this->translator->trans('One-page checkout is not enabled.', [], ModuleTranslation::SHOP_DOMAIN) ); } @@ -136,11 +133,7 @@ public function handle(array $requestParameters): array if (!$this->isTokenValid($requestParameters)) { return $this->errorResponse( self::ERROR_FIELD_TOKEN, - $this->translator->trans( - 'Invalid security token.', - [], - 'Shop.Notifications.Error' - ), + $this->translator->trans('Invalid security token.', [], ModuleTranslation::SHOP_DOMAIN), false ); } @@ -480,11 +473,7 @@ private function resolveGuestEmail(string $submittedEmail, ExistingCustomerState if (!$this->customerPersister->save($existingCustomer, '', '', false)) { return $this->errorResponse( self::ERROR_FIELD_EMAIL, - $this->translator->trans( - self::ERROR_GUEST_EMAIL_UPDATE_FAILED, - [], - 'Shop.Notifications.Error' - ) + $this->translator->trans(self::ERROR_GUEST_EMAIL_UPDATE_FAILED, [], ModuleTranslation::SHOP_DOMAIN) ); } @@ -697,11 +686,7 @@ private function cartSyncErrorResponse(): array { return $this->errorResponse( self::ERROR_FIELD_GLOBAL, - $this->translator->trans( - self::ERROR_CART_CUSTOMER_SYNC_FAILED, - [], - 'Shop.Notifications.Error' - ) + $this->translator->trans(self::ERROR_CART_CUSTOMER_SYNC_FAILED, [], ModuleTranslation::SHOP_DOMAIN) ); } diff --git a/src/Checkout/Ajax/OrderOptions/OnePageCheckoutGiftWrappingHandler.php b/src/Checkout/Ajax/OrderOptions/OnePageCheckoutGiftWrappingHandler.php new file mode 100644 index 0000000..d21b520 --- /dev/null +++ b/src/Checkout/Ajax/OrderOptions/OnePageCheckoutGiftWrappingHandler.php @@ -0,0 +1,112 @@ +context = $context; + $this->translator = $translator; + $this->checkoutSessionFactory = $checkoutSessionFactory ?? new CheckoutSessionFactory($context, $translator); + $this->cartPresenterHelper = $cartPresenterHelper ?? new CartPresenterHelper($context); + } + + /** + * @param array $requestParameters + * + * @return array + */ + public function handle(array $requestParameters = []): array + { + if (!\Validate::isLoadedObject($this->context->cart)) { + return CheckoutAjaxResponse::error( + $this->translator->trans('Unable to resolve the current cart.', [], ModuleTranslation::SHOP_DOMAIN) + ); + } + + if (!(bool) \Configuration::get('PS_GIFT_WRAPPING')) { + return CheckoutAjaxResponse::error( + $this->translator->trans('Gift wrapping is currently unavailable.', [], ModuleTranslation::SHOP_DOMAIN) + ); + } + + $checkoutSession = $this->checkoutSessionFactory->create(); + $useGift = !empty($requestParameters['gift']); + $giftMessage = $useGift ? (string) ($requestParameters['gift_message'] ?? '') : ''; + + $checkoutSession->setGift($useGift, $giftMessage); + + if ((int) $this->context->cart->id_address_delivery <= 0) { + return $this->presentWithTempAddress($requestParameters); + } + + $cart = $this->cartPresenterHelper->presentCart(); + + return [ + 'success' => true, + 'cart' => $cart, + 'totals' => $cart['totals'], + ]; + } + + /** + * @param array $requestParameters + * + * @return array + */ + private function presentWithTempAddress(array $requestParameters): array + { + $originalAddressId = (int) $this->context->cart->id_address_delivery; + $tempAddress = new OpcTempAddress($this->context); + $tempAddressId = 0; + + try { + // Prefer address params from the persisted cookie (set during carrier selection) + // so this works even when the JS doesn't forward address fields. + $storedParams = (new TempAddressStorage($this->context))->get(); + $addressParams = $storedParams !== [] ? $storedParams : $requestParameters; + + $tempAddressId = $tempAddress->createFromRequest($addressParams); + + if ($tempAddressId > 0) { + $persistedOption = (new TempAddressCarrierSelectionStorage($this->context))->get(); + if ($persistedOption !== '') { + $this->checkoutSessionFactory->create()->setDeliveryOption([ + $tempAddressId => $persistedOption, + ]); + } + } + + $cart = $this->cartPresenterHelper->presentCart(); + + return [ + 'success' => true, + 'cart' => $cart, + 'totals' => $cart['totals'], + ]; + } finally { + if ($tempAddressId > 0) { + $tempAddress->cleanup($tempAddressId, $originalAddressId); + } + } + } +} diff --git a/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php b/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php index 4afde7d..4d40a1a 100644 --- a/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php +++ b/src/Checkout/Ajax/Payment/OnePageCheckoutSelectPaymentHandler.php @@ -2,13 +2,18 @@ namespace PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; +use Symfony\Contracts\Translation\TranslatorInterface; + class OnePageCheckoutSelectPaymentHandler { private \Context $context; + private TranslatorInterface $translator; - public function __construct(\Context $context) + public function __construct(\Context $context, TranslatorInterface $translator) { $this->context = $context; + $this->translator = $translator; } /** @@ -23,7 +28,9 @@ public function handle(array $requestParameters = []): array $paymentSelectionKey = $requestParameters['payment_selection_key'] ?? null; if ($this->hasMissingPaymentSelectionPayload($paymentOption, $paymentModule, $paymentSelectionKey)) { - return CheckoutAjaxResponse::error('Missing payment selection payload'); + return CheckoutAjaxResponse::error( + $this->translator->trans('Missing payment selection payload.', [], ModuleTranslation::SHOP_DOMAIN) + ); } $this->context->cookie->__set('opc_selected_payment_option', $paymentOption); diff --git a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php index 50bf820..153b851 100644 --- a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitHandler.php @@ -25,10 +25,27 @@ public function __construct( /** * @param array $requestParameters + * @param array $reloadErrorResponse * * @return array */ - public function handle(array $requestParameters): array + public function handle(array $requestParameters, array $reloadErrorResponse = []): array + { + try { + return $this->processSubmit($requestParameters); + } catch (\Throwable $exception) { + $this->persistErrorsForNextReload($requestParameters, $reloadErrorResponse); + + throw $exception; + } + } + + /** + * @param array $requestParameters + * + * @return array + */ + private function processSubmit(array $requestParameters): array { $checkoutSession = $this->checkoutSessionFactory->create(); $this->persistSelectedPaymentModule($requestParameters); @@ -43,17 +60,21 @@ public function handle(array $requestParameters): array ]; } - $this->submitValidationStateStorage->save([ - 'validation_errors' => isset($processingResult['validation_errors']) && is_array($processingResult['validation_errors']) - ? $processingResult['validation_errors'] - : [], - 'form_errors' => isset($processingResult['form_errors']) && is_array($processingResult['form_errors']) - ? $processingResult['form_errors'] - : [], - 'submitted_values' => isset($processingResult['submitted_values']) && is_array($processingResult['submitted_values']) - ? $processingResult['submitted_values'] - : [], - ]); + $persistedState = isset($processingResult['persisted_state']) && is_array($processingResult['persisted_state']) + ? $processingResult['persisted_state'] + : [ + 'validation_errors' => isset($processingResult['validation_errors']) && is_array($processingResult['validation_errors']) + ? $processingResult['validation_errors'] + : [], + 'form_errors' => isset($processingResult['form_errors']) && is_array($processingResult['form_errors']) + ? $processingResult['form_errors'] + : [], + 'submitted_values' => isset($processingResult['submitted_values']) && is_array($processingResult['submitted_values']) + ? $processingResult['submitted_values'] + : [], + ]; + + $this->persistFailedSubmitState($persistedState); return [ 'success' => false, @@ -62,6 +83,30 @@ public function handle(array $requestParameters): array ]; } + /** + * @param array $requestParameters + * @param array $response + */ + private function persistErrorsForNextReload(array $requestParameters, array $response): void + { + if (empty($response['reload']) || empty($response['errors'])) { + return; + } + + try { + $this->persistFailedSubmitState([ + 'validation_errors' => [], + 'form_errors' => $response['errors'], + 'submitted_values' => $requestParameters, + ]); + } catch (\Throwable $exception) { + \PrestaShopLogger::addLog( + sprintf('ps_onepagecheckout opcSubmit technical error state could not be persisted: %s', $exception->getMessage()), + 3 + ); + } + } + /** * @param array $requestParameters */ @@ -76,4 +121,26 @@ private function persistSelectedPaymentModule(array $requestParameters): void $this->context->cookie->__set('opc_selected_payment_module', $paymentMethod); $this->context->cookie->write(); } + + /** + * @param array $state + */ + private function persistFailedSubmitState(array $state): void + { + $this->submitValidationStateStorage->save([ + 'cart_id' => $this->getRequiredCurrentCartId(), + ] + $state); + } + + private function getRequiredCurrentCartId(): int + { + $cart = $this->context->cart ?? null; + $cartId = (int) ($cart->id ?? 0); + + if ($cartId <= 0) { + throw new \RuntimeException('Cannot persist one-page checkout submit state without an active cart.'); + } + + return $cartId; + } } diff --git a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php index 97ff9e8..7b5c161 100644 --- a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitProcessor.php @@ -4,6 +4,7 @@ use PrestaShop\Module\PsOnePageCheckout\Checkout\PaymentSelectionKeyBuilder; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutSubmitProcessor @@ -132,11 +133,7 @@ private function validateIdentitySection(array $requestParameters): bool } $this->validationErrors['identity'] = [ - 'email' => $this->translator->trans( - 'Invalid email format.', - [], - 'Shop.Notifications.Error' - ), + 'email' => $this->translator->trans('Invalid email format.', [], ModuleTranslation::SHOP_DOMAIN), ]; return false; @@ -174,11 +171,7 @@ private function validateShippingSection(\CheckoutSession $checkoutSession, arra if ($deliveryOptionKey === '' || empty($deliveryOptions[$deliveryOptionKey])) { $this->validationErrors['shipping'] = [ - 'delivery_option' => $this->translator->trans( - 'Please select a shipping method.', - [], - 'Shop.Notifications.Error' - ), + 'delivery_option' => $this->translator->trans('Please select a shipping method.', [], ModuleTranslation::SHOP_DOMAIN), ]; return false; @@ -218,11 +211,7 @@ private function validatePaymentSection(\CheckoutSession $checkoutSession, array } $this->validationErrors['payment'] = [ - 'paymentMethod' => $this->translator->trans( - 'Please select a payment method.', - [], - 'Shop.Notifications.Error' - ), + 'paymentMethod' => $this->translator->trans('Please select a payment method.', [], ModuleTranslation::SHOP_DOMAIN), ]; return false; @@ -246,11 +235,7 @@ private function validateConditionsSection(array $requestParameters): bool } $this->validationErrors['conditions'] = [ - 'conditions_to_approve' => $this->translator->trans( - 'Please accept the terms of service.', - [], - 'Shop.Notifications.Error' - ), + 'conditions_to_approve' => $this->translator->trans('Please accept the terms of service.', [], ModuleTranslation::SHOP_DOMAIN), ]; return false; @@ -351,11 +336,17 @@ static function ($carry, $item) { */ private function buildFailureResult(array $submittedValues): array { + $persistedSubmittedValues = $this->filterPersistedSubmittedValues($submittedValues); + return [ 'success' => false, 'validation_errors' => $this->validationErrors, 'form_errors' => $this->opcForm->getErrors(), - 'submitted_values' => $this->filterPersistedSubmittedValues($submittedValues), + 'submitted_values' => $persistedSubmittedValues, + 'persisted_state' => $this->opcForm->buildPersistedSubmissionState( + $persistedSubmittedValues, + $this->validationErrors + ), ]; } @@ -374,12 +365,47 @@ private function filterPersistedSubmittedValues(array $submittedValues): array $submittedValues['paymentMethod'] ); + $submittedValues = $this->filterPersistableAddressSelections($submittedValues); + return array_filter( $submittedValues, static fn ($value): bool => is_scalar($value) || is_array($value) || $value === null ); } + /** + * Keep only address ids that still resolve to a real address owned by the current checkout customer. + * This avoids restoring a stale temp preview address as a selected saved address after a failed submit. + * + * @param array $submittedValues + * + * @return array + */ + private function filterPersistableAddressSelections(array $submittedValues): array + { + foreach (['id_address_delivery', 'id_address_invoice'] as $fieldName) { + $addressId = (int) ($submittedValues[$fieldName] ?? 0); + + if ($addressId <= 0 || !$this->isOwnedPersistableAddressId($addressId)) { + unset($submittedValues[$fieldName]); + } + } + + return $submittedValues; + } + + private function isOwnedPersistableAddressId(int $addressId): bool + { + $customerId = (int) ($this->context->customer->id ?? 0); + if ($addressId <= 0 || $customerId <= 0 || !\Customer::customerHasAddress($customerId, $addressId)) { + return false; + } + + $address = new \Address($addressId, (int) ($this->context->language->id ?? 0)); + + return \Validate::isLoadedObject($address) && (int) $address->deleted === 0; + } + private function getSelectedPaymentModule(): string { if (!isset($this->context->cookie)) { diff --git a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php index e50527a..a062379 100644 --- a/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php +++ b/src/Checkout/Ajax/Submit/OnePageCheckoutSubmitValidationStateStorage.php @@ -4,7 +4,7 @@ class OnePageCheckoutSubmitValidationStateStorage { - private const COOKIE_KEY = 'opc_submit_validation_state'; + private const STORAGE_KEY = '_opc_submit_validation_state'; private \Context $context; @@ -18,17 +18,14 @@ public function __construct(\Context $context) */ public function save(array $state): void { - if (!$this->hasCookie()) { + if ($this->getCartId() <= 0) { return; } - $encodedState = json_encode($state); - if ($encodedState === false) { - return; - } + $checkoutSessionData = $this->readCheckoutSessionData(); + $checkoutSessionData[self::STORAGE_KEY] = $state; - $this->context->cookie->__set(self::COOKIE_KEY, $encodedState); - $this->writeCookie(); + $this->writeCheckoutSessionData($checkoutSessionData); } /** @@ -36,46 +33,95 @@ public function save(array $state): void */ public function consume(): array { - if (!$this->hasCookie()) { + $checkoutSessionData = $this->readCheckoutSessionData(); + $submitState = $checkoutSessionData[self::STORAGE_KEY] ?? null; + + if (!is_array($submitState)) { return []; } - $rawState = (string) ($this->context->cookie->__get(self::COOKIE_KEY) ?: ''); - $this->clear(); + unset($checkoutSessionData[self::STORAGE_KEY]); + $this->writeCheckoutSessionData($checkoutSessionData); + + return $submitState; + } + + public function clear(): void + { + $checkoutSessionData = $this->readCheckoutSessionData(); + if (!array_key_exists(self::STORAGE_KEY, $checkoutSessionData)) { + return; + } + + unset($checkoutSessionData[self::STORAGE_KEY]); + $this->writeCheckoutSessionData($checkoutSessionData); + } + + /** + * @return array + */ + private function readCheckoutSessionData(): array + { + $rawCheckoutSessionData = ''; + + if (isset($this->context->cart->checkout_session_data) && is_string($this->context->cart->checkout_session_data)) { + $rawCheckoutSessionData = $this->context->cart->checkout_session_data; + } elseif ($this->getCartId() > 0) { + $rawCheckoutSessionData = (string) $this->getDb()->getValue(sprintf( + 'SELECT checkout_session_data FROM %scart WHERE id_cart = %d', + _DB_PREFIX_, + $this->getCartId() + )); + } - if ($rawState === '') { + if ($rawCheckoutSessionData === '') { return []; } - $decodedState = json_decode($rawState, true); + $decodedCheckoutSessionData = json_decode($rawCheckoutSessionData, true); - return is_array($decodedState) ? $decodedState : []; + return is_array($decodedCheckoutSessionData) ? $decodedCheckoutSessionData : []; } - public function clear(): void + /** + * @param array $checkoutSessionData + */ + private function writeCheckoutSessionData(array $checkoutSessionData): void { - if (!$this->hasCookie()) { + if ($this->getCartId() <= 0) { + return; + } + + $encodedCheckoutSessionData = $checkoutSessionData === [] ? null : json_encode($checkoutSessionData); + if ($checkoutSessionData !== [] && $encodedCheckoutSessionData === false) { return; } - if (method_exists($this->context->cookie, '__unset')) { - $this->context->cookie->__unset(self::COOKIE_KEY); - } else { - $this->context->cookie->__set(self::COOKIE_KEY, ''); + if (isset($this->context->cart)) { + // Legacy PrestaShop ObjectModel instances expose table-backed fields dynamically at runtime. + /* @phpstan-ignore-next-line */ + $this->context->cart->checkout_session_data = $encodedCheckoutSessionData ?? ''; } - $this->writeCookie(); + $serializedCheckoutSessionData = $encodedCheckoutSessionData === null + ? 'NULL' + : '"' . $this->getDb()->escape($encodedCheckoutSessionData) . '"'; + + $this->getDb()->execute(sprintf( + 'UPDATE %scart SET checkout_session_data = %s WHERE id_cart = %d', + _DB_PREFIX_, + $serializedCheckoutSessionData, + $this->getCartId() + )); } - private function hasCookie(): bool + private function getCartId(): int { - return isset($this->context->cookie); + return isset($this->context->cart) ? (int) ($this->context->cart->id ?? 0) : 0; } - private function writeCookie(): void + protected function getDb() { - if (method_exists($this->context->cookie, 'write')) { - $this->context->cookie->write(); - } + return \Db::getInstance(); } } diff --git a/src/Checkout/Ajax/TempAddressStorage.php b/src/Checkout/Ajax/TempAddressStorage.php new file mode 100644 index 0000000..866ea71 --- /dev/null +++ b/src/Checkout/Ajax/TempAddressStorage.php @@ -0,0 +1,71 @@ +context = $context; + } + + /** + * @return array + */ + public function get(): array + { + if (!isset($this->context->cookie)) { + return []; + } + + $raw = (string) ($this->context->cookie->{self::COOKIE_KEY} ?: ''); + if ($raw === '') { + return []; + } + + $data = json_decode($raw, true); + + return is_array($data) ? $data : []; + } + + /** + * @param array $requestParameters + */ + public function saveFromRequest(array $requestParameters): void + { + if (!isset($this->context->cookie)) { + return; + } + + $params = []; + foreach (['id_country', 'id_state', 'postcode', 'city'] as $field) { + $value = (string) ($requestParameters[$field] + ?? $requestParameters["delivery_{$field}"] + ?? ''); + if ($value !== '') { + $params[$field] = $value; + } + } + + if (empty($params)) { + return; + } + + $this->context->cookie->{self::COOKIE_KEY} = json_encode($params); + $this->context->cookie->write(); + } + + public function clear(): void + { + if (!isset($this->context->cookie)) { + return; + } + + unset($this->context->cookie->{self::COOKIE_KEY}); + $this->context->cookie->write(); + } +} diff --git a/src/Checkout/CheckoutOnePageStep.php b/src/Checkout/CheckoutOnePageStep.php index 3bbe6ce..2dffc51 100644 --- a/src/Checkout/CheckoutOnePageStep.php +++ b/src/Checkout/CheckoutOnePageStep.php @@ -29,6 +29,8 @@ use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\Submit\OnePageCheckoutSubmitValidationStateStorage; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; +use PrestaShop\PrestaShop\Adapter\Product\PriceFormatter; use Symfony\Contracts\Translation\TranslatorInterface; class CheckoutOnePageStep extends \AbstractCheckoutStep @@ -149,6 +151,31 @@ public function getDisplayTaxesLabel() return $this->displayTaxesLabel; } + public function getGiftCostForLabel() + { + if ($this->getGiftCost() == 0) { + return ''; + } + + $taxLabel = ''; + if ($this->getDisplayTaxesLabel()) { + $taxLabel = $this->getTranslator()->trans( + $this->getIncludeTaxes() ? 'tax incl.' : 'tax excl.', + [], + 'Shop.Theme.Checkout' + ); + } + + return $this->getTranslator()->trans( + '(additional cost of %giftcost% %taxlabel%)', + [ + '%giftcost%' => (new PriceFormatter())->convertAndFormat($this->getGiftCost()), + '%taxlabel%' => $taxLabel, + ], + 'Shop.Theme.Checkout' + ); + } + public function handleRequest(array $requestParameters = []) { // Step is always reachable (single step) @@ -166,11 +193,7 @@ public function handleRequest(array $requestParameters = []) } $this->setTitle( - $this->getTranslator()->trans( - 'Checkout', - [], - 'Shop.Theme.Checkout' - ) + $this->getTranslator()->trans('Checkout', [], ModuleTranslation::SHOP_DOMAIN) ); } @@ -275,6 +298,11 @@ public function render(array $extraParams = []) 'gift' => [ 'allowed' => $this->isGiftAllowed(), 'isGift' => $this->getCheckoutSession()->getGift()['isGift'], + 'label' => $this->getTranslator()->trans( + 'I would like my order to be gift wrapped %cost%', + ['%cost%' => $this->getGiftCostForLabel()], + 'Shop.Theme.Checkout' + ), 'message' => $this->getCheckoutSession()->getGift()['message'], ], 'is_virtual_cart' => $this->context->cart->isVirtualCart(), @@ -309,6 +337,13 @@ private function restoreLastFailedSubmitState(): void return; } + $storedCartId = isset($submitState['cart_id']) ? (int) $submitState['cart_id'] : 0; + $currentCartId = isset($this->context->cart) ? (int) ($this->context->cart->id ?? 0) : 0; + + if ($storedCartId > 0 && $currentCartId > 0 && $storedCartId !== $currentCartId) { + return; + } + $submittedValues = isset($submitState['submitted_values']) && is_array($submitState['submitted_values']) ? $submitState['submitted_values'] : []; diff --git a/src/Form/BackOfficeConfigurationForm.php b/src/Form/BackOfficeConfigurationForm.php index b58d66b..04ace86 100644 --- a/src/Form/BackOfficeConfigurationForm.php +++ b/src/Form/BackOfficeConfigurationForm.php @@ -30,6 +30,7 @@ namespace PrestaShop\Module\PsOnePageCheckout\Form; use PrestaShop\Module\PsOnePageCheckout\Analytics\Analytics; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Twig\Environment; class BackOfficeConfigurationForm @@ -241,7 +242,7 @@ private function registerBackOfficeAssets(): void ); } - private function trans(string $message, string $domain = 'Modules.PsOnePageCheckout.Admin'): string + private function trans(string $message, string $domain = ModuleTranslation::ADMIN_DOMAIN): string { $translator = \Context::getContext()->getTranslator(); diff --git a/src/Form/OnePageCheckoutForm.php b/src/Form/OnePageCheckoutForm.php index bf7c290..60d6267 100644 --- a/src/Form/OnePageCheckoutForm.php +++ b/src/Form/OnePageCheckoutForm.php @@ -33,6 +33,7 @@ use Customer; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerTemplateBuilder; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use PrestaShop\PrestaShop\Core\Util\InternationalizedDomainNameConverter; use Symfony\Contracts\Translation\TranslatorInterface; use Validate; @@ -49,6 +50,9 @@ class OnePageCheckoutForm extends \AbstractForm */ protected $formatter; + /** + * @var \Context + */ private $context; private $customerPersister; private $addressPersister; @@ -196,13 +200,13 @@ public function restoreSubmissionState(array $params, array $errors): self $this->fillWith($params); $this->errors = ['' => is_array($errors[''] ?? null) ? $errors[''] : []]; - foreach ($errors as $fieldName => $fieldErrors) { - if ($fieldName === '' || !is_array($fieldErrors)) { + foreach ($errors as $fieldKey => $fieldErrors) { + if ($fieldKey === '' || !is_array($fieldErrors)) { continue; } - $field = $this->getField((string) $fieldName); - if ($field) { + $field = $this->getField((string) $fieldKey); + if ($field instanceof \FormField) { $field->setErrors($fieldErrors); } } @@ -210,6 +214,21 @@ public function restoreSubmissionState(array $params, array $errors): self return $this; } + /** + * @param array $submittedValues + * @param array $validationErrors + * + * @return array + */ + public function buildPersistedSubmissionState(array $submittedValues, array $validationErrors): array + { + return [ + 'validation_errors' => $validationErrors, + 'form_errors' => $this->getPersistableErrors(), + 'submitted_values' => $submittedValues, + ]; + } + public function validate() { // When use_same_address, invoice fields are optional (skipped) @@ -266,12 +285,7 @@ public function submit() $customer->is_guest = true; if (!$this->customerPersister->save($customer, '', '', false)) { - $errors = $this->customerPersister->getErrors(); - foreach ($errors as $field => $fieldErrors) { - foreach ($fieldErrors as $error) { - $this->errors[$field][] = $error; - } - } + $this->applyFieldErrors($this->customerPersister->getErrors()); return false; } @@ -298,7 +312,7 @@ public function submit() ); $deliveryAddress->id_customer = $customer->id; if (empty($deliveryAddress->alias)) { - $deliveryAddress->alias = $this->translator->trans('My Address', [], 'Shop.Theme.Checkout'); + $deliveryAddress->alias = $this->translator->trans('My Address', [], ModuleTranslation::SHOP_DOMAIN); } \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$deliveryAddress]); if (!$this->addressPersister->save($deliveryAddress, $token)) { @@ -324,11 +338,7 @@ public function submit() 'invoice_' ); $invoiceAddress->id_customer = $customer->id; - $invoiceAddress->alias = $invoiceAddress->alias ?: $this->translator->trans( - 'Invoice address', - [], - 'Shop.Theme.Checkout' - ); + $invoiceAddress->alias = $invoiceAddress->alias ?: $this->translator->trans('Invoice address', [], ModuleTranslation::SHOP_DOMAIN); \Hook::exec('actionSubmitCustomerAddressForm', ['address' => &$invoiceAddress]); if (!$this->addressPersister->save($invoiceAddress, $token)) { return false; @@ -527,12 +537,7 @@ private function persistGuestCustomer(\Customer $customer): bool return true; } - $errors = $this->customerPersister->getErrors(); - foreach ($errors as $field => $fieldErrors) { - foreach ($fieldErrors as $error) { - $this->errors[$field][] = $error; - } - } + $this->applyFieldErrors($this->customerPersister->getErrors()); return false; } @@ -554,11 +559,7 @@ private function isGuestInitEmailValid(): bool } $emailField->addError( - $this->translator->trans( - 'Invalid email format.', - [], - 'Shop.Notifications.Error' - ) + $this->translator->trans('Invalid email format.', [], ModuleTranslation::SHOP_DOMAIN) ); return false; @@ -882,4 +883,41 @@ private function convertFieldToTemplateArray(?\FormField $field): ?array { return $field ? $field->toArray() : null; } + + /** + * @return array> + */ + private function getPersistableErrors(): array + { + $persistableErrors = [ + '' => is_array($this->errors[''] ?? null) ? $this->errors[''] : [], + ]; + + foreach ($this->formFields as $fieldKey => $field) { + $fieldErrors = $field->getErrors(); + + if ($fieldErrors !== []) { + $persistableErrors[$fieldKey] = $fieldErrors; + } + } + + return $persistableErrors; + } + + /** + * @param array> $errorsByField + */ + private function applyFieldErrors(array $errorsByField): void + { + foreach ($errorsByField as $fieldKey => $fieldErrors) { + if ($fieldErrors === []) { + continue; + } + + $field = $this->getField((string) $fieldKey); + if ($field instanceof \FormField) { + $field->setErrors($fieldErrors); + } + } + } } diff --git a/src/Form/OnePageCheckoutFormatter.php b/src/Form/OnePageCheckoutFormatter.php index 38c33b2..fc1a8ce 100644 --- a/src/Form/OnePageCheckoutFormatter.php +++ b/src/Form/OnePageCheckoutFormatter.php @@ -29,6 +29,7 @@ use Address; use Country; +use PrestaShop\Module\PsOnePageCheckout\Translation\ModuleTranslation; use Symfony\Contracts\Translation\TranslatorInterface; class OnePageCheckoutFormatter implements \FormFormatterInterface @@ -151,11 +152,7 @@ public function getFormat() ->setName('use_same_address') ->setType('checkbox') ->setLabel( - $this->translator->trans( - 'Use the same address for invoice', - [], - 'Shop.Theme.Checkout' - ) + $this->translator->trans('Use the same address for invoice', [], ModuleTranslation::SHOP_DOMAIN) ) ->setValue(true); diff --git a/src/Translation/ModuleTranslation.php b/src/Translation/ModuleTranslation.php new file mode 100644 index 0000000..e42b89c --- /dev/null +++ b/src/Translation/ModuleTranslation.php @@ -0,0 +1,9 @@ +errors = $errors; + + return $this; + } + public function getErrors(): array { return $this->errors; diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php index 2e1e372..587ba17 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressFormHandlerIntegrationTest.php @@ -51,6 +51,7 @@ public function testItLoadsAddressThenAppliesWhitelistedPayload(): void ['customer_id' => (int) $customer->id], [ 'id_country' => '8', + 'invoice_id_country' => '8', 'use_same_address' => '1', 'id_address_delivery' => (string) $address->id, ], @@ -169,7 +170,7 @@ public function testItFallsBackToDefaultCountryWhenCustomerHasNoSavedAddress(): self::assertSame('
default-country
', $response['address_form']); } - public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void + public function testItPreservesInvoiceCountryWhenUseSameAddressIsEnabled(): void { $customer = $this->createCustomer($this->uniqueEmail('opc-same-address-country')); self::getContext()->customer = $customer; @@ -188,6 +189,7 @@ public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void ['customer_id' => (int) $customer->id], [ 'id_country' => '21', + 'invoice_id_country' => '8', 'use_same_address' => '1', ], ], $formSpy->fillWithPayloads); diff --git a/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php index 77928bc..b1cde7e 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcAddressesListHandlerIntegrationTest.php @@ -41,7 +41,11 @@ public function testItReturnsCurrentCheckoutCustomerAddressListContext(): void $context->cart->id_address_delivery = (int) $secondAddress->id; $context->cart->id_address_invoice = (int) $firstAddress->id; - $handler = new OnePageCheckoutAddressesListHandler($context, new CheckoutCustomerContextResolver($context)); + $handler = new OnePageCheckoutAddressesListHandler( + $context, + $this->createConfiguredMock(\Symfony\Contracts\Translation\TranslatorInterface::class, ['trans' => 'translated']), + new CheckoutCustomerContextResolver($context) + ); $response = $handler->handle(); self::assertTrue($response['success']); diff --git a/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php index 7b4c60a..5ed5a22 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcSaveAddressHandlerIntegrationTest.php @@ -54,7 +54,9 @@ public function testItCreatesDeliveryAddressAndUpdatesCart(): void 'use_same_address' => '1', ]); - self::assertSame(['success' => true], $response, var_export($response, true)); + self::assertSame(true, $response['success'] ?? null, var_export($response, true)); + self::assertSame('delivery', $response['address_type'] ?? null, var_export($response, true)); + self::assertIsInt($response['id_address'] ?? null, var_export($response, true)); $freshCart = new \Cart((int) $cart->id); self::assertGreaterThan(0, (int) $freshCart->id_address_delivery); @@ -88,7 +90,9 @@ public function testItCreatesDeliveryAddressWithoutAliasAndUsesTheFallbackAlias( 'use_same_address' => '1', ]); - self::assertSame(['success' => true], $response, var_export($response, true)); + self::assertSame(true, $response['success'] ?? null, var_export($response, true)); + self::assertSame('delivery', $response['address_type'] ?? null, var_export($response, true)); + self::assertIsInt($response['id_address'] ?? null, var_export($response, true)); $savedAddress = new \Address((int) $context->cart->id_address_delivery); self::assertSame('My Address', $savedAddress->alias); @@ -124,7 +128,9 @@ public function testItCreatesDeliveryAddressForCountryWithStatesWhenRequestChang 'use_same_address' => '1', ]); - self::assertSame(['success' => true], $response, var_export($response, true)); + self::assertSame(true, $response['success'] ?? null, var_export($response, true)); + self::assertSame('delivery', $response['address_type'] ?? null, var_export($response, true)); + self::assertIsInt($response['id_address'] ?? null, var_export($response, true)); $savedAddress = new \Address((int) $context->cart->id_address_delivery); self::assertSame($unitedStatesId, (int) $savedAddress->id_country); @@ -166,7 +172,9 @@ public function testItCreatesInvoiceAddressWithoutChangingTheDeliveryAddress(): 'invoice_alias' => 'Invoice', ]); - self::assertSame(['success' => true], $response, var_export($response, true)); + self::assertSame(true, $response['success'] ?? null, var_export($response, true)); + self::assertSame('invoice', $response['address_type'] ?? null, var_export($response, true)); + self::assertIsInt($response['id_address'] ?? null, var_export($response, true)); $freshCart = new \Cart((int) $cart->id); self::assertSame((int) $deliveryAddress->id, (int) $freshCart->id_address_delivery); @@ -209,7 +217,9 @@ public function testItUpdatesAnOwnedAddress(): void 'use_same_address' => '1', ]); - self::assertSame(['success' => true], $response, var_export($response, true)); + self::assertSame(true, $response['success'] ?? null, var_export($response, true)); + self::assertSame('delivery', $response['address_type'] ?? null, var_export($response, true)); + self::assertSame((int) $address->id, $response['id_address'] ?? null, var_export($response, true)); $freshAddress = new \Address((int) $address->id); self::assertSame('99 rue Modifiee', $freshAddress->address1); diff --git a/tests/php/Integration/Checkout/Ajax/OpcSelectPaymentHandlerIntegrationTest.php b/tests/php/Integration/Checkout/Ajax/OpcSelectPaymentHandlerIntegrationTest.php index 18f1e1a..92cb76d 100644 --- a/tests/php/Integration/Checkout/Ajax/OpcSelectPaymentHandlerIntegrationTest.php +++ b/tests/php/Integration/Checkout/Ajax/OpcSelectPaymentHandlerIntegrationTest.php @@ -27,7 +27,10 @@ protected function setUp(): void public function testItPersistsSelectedPaymentValuesOnCookie(): void { $context = self::getContext(); - $handler = new OnePageCheckoutSelectPaymentHandler($context); + $handler = new OnePageCheckoutSelectPaymentHandler( + $context, + $this->createConfiguredMock(\Symfony\Contracts\Translation\TranslatorInterface::class, ['trans' => 'translated']) + ); $response = $handler->handle([ 'payment_option' => 'payment-option-1', diff --git a/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php index e083297..92789ef 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcAddressFormHandlerTest.php @@ -52,6 +52,7 @@ public function testItBuildsTemplateVariablesFromWhitelistedPayload(): void ->method('fillWith') ->with([ 'id_country' => '8', + 'invoice_id_country' => '8', 'use_same_address' => '1', ]) ; @@ -138,7 +139,7 @@ public function testItDoesNotFillAddressOrFormWhenPayloadHasNoExpectedKeys(): vo self::assertSame('
initial
', $response['address_form']); } - public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void + public function testItPreservesInvoiceCountryWhenUseSameAddressIsEnabled(): void { $resolver = $this->createResolverReturning(null); $handler = new OnePageCheckoutAddressFormHandler($this->opcForm, \Context::getContext(), $resolver); @@ -156,6 +157,7 @@ public function testItIgnoresInvoiceCountryWhenUseSameAddressIsEnabled(): void ->method('fillWith') ->with([ 'id_country' => '21', + 'invoice_id_country' => '8', 'use_same_address' => '1', ]) ; diff --git a/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php index 185f3ef..504cf9f 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcAddressesListHandlerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\CheckoutCustomerContextResolver; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutAddressesListHandler; +use Symfony\Contracts\Translation\TranslatorInterface; use Tests\Fixtures\CheckoutTestFixtures; class OpcAddressesListHandlerTest extends TestCase @@ -28,8 +29,9 @@ public function testItReturnsRenderableAddressListContext(): void $resolver = $this->createMock(CheckoutCustomerContextResolver::class); $resolver->method('resolve')->willReturn($customer); + $translator = $this->createConfiguredMock(TranslatorInterface::class, ['trans' => 'translated']); - $handler = new OnePageCheckoutAddressesListHandler($context, $resolver); + $handler = new OnePageCheckoutAddressesListHandler($context, $translator, $resolver); $response = $handler->handle(); self::assertTrue($response['success']); diff --git a/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php index e0d5958..69d3283 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcSaveAddressHandlerTest.php @@ -15,6 +15,7 @@ class OpcSaveAddressHandlerTest extends TestCase public function testItReturnsTechnicalErrorsUnderTheErrorsKeyWhenCustomerCannotBeResolved(): void { $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); $resolver = $this->createMock(CheckoutCustomerContextResolver::class); $resolver->method('resolveId')->willReturn(0); diff --git a/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php index e684878..4df978c 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcSelectPaymentHandlerTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\OnePageCheckoutSelectPaymentHandler; +use Symfony\Contracts\Translation\TranslatorInterface; class OpcSelectPaymentHandlerTest extends TestCase { @@ -36,7 +37,10 @@ public function __construct() }; $context->cookie = $cookie; - $handler = new OnePageCheckoutSelectPaymentHandler($context); + $handler = new OnePageCheckoutSelectPaymentHandler( + $context, + $this->createConfiguredMock(TranslatorInterface::class, ['trans' => 'translated']) + ); $response = $handler->handle([ 'payment_option' => 'payment-option-1', 'payment_module' => 'ps_wirepayment', diff --git a/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php b/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php index ee7725f..616949d 100644 --- a/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php +++ b/tests/php/Unit/Checkout/Ajax/OpcSubmitHandlerTest.php @@ -23,6 +23,8 @@ class OpcSubmitHandlerTest extends TestCase protected function setUp(): void { $this->context = $this->createMock(\Context::class); + $this->context->cart = new \stdClass(); + $this->context->cart->id = 42; $this->context->cookie = new class { public array $values = []; public bool $written = false; @@ -106,6 +108,7 @@ public function testHandlePersistsFailedSubmitStateAndReturnsReload(): void $this->submitValidationStateStorage->expects($this->once()) ->method('save') ->with([ + 'cart_id' => 42, 'validation_errors' => [ 'payment' => ['paymentMethod' => 'Please select a payment method.'], ], @@ -124,4 +127,60 @@ public function testHandlePersistsFailedSubmitStateAndReturnsReload(): void self::assertTrue($response['reload']); self::assertSame('/commande', $response['checkout_url']); } + + public function testHandlePersistsReloadErrorsAndRethrowsWhenSubmitFails(): void + { + $this->submitProcessor->expects($this->once()) + ->method('process') + ->willThrowException(new \RuntimeException('submit exploded')); + $this->submitValidationStateStorage->expects($this->once()) + ->method('save') + ->with([ + 'cart_id' => 42, + 'validation_errors' => [], + 'form_errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], + 'submitted_values' => [ + 'email' => 'customer@example.com', + ], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('submit exploded'); + + $this->handler->handle( + [ + 'email' => 'customer@example.com', + ], + [ + 'reload' => true, + 'errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], + ] + ); + } + + public function testHandleFailsWhenFailedSubmitStateCannotBeLinkedToACart(): void + { + $this->context->cart = new \stdClass(); + $this->context->cart->id = 0; + $this->submitProcessor->expects($this->once()) + ->method('process') + ->willReturn([ + 'success' => false, + 'validation_errors' => [], + 'form_errors' => [ + 'firstname' => ['Required'], + ], + 'submitted_values' => [], + ]); + $this->submitValidationStateStorage->expects($this->never())->method('save'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot persist one-page checkout submit state without an active cart.'); + + $this->handler->handle([]); + } } diff --git a/tests/php/Unit/Checkout/Ajax/OpcSubmitValidationStateStorageTest.php b/tests/php/Unit/Checkout/Ajax/OpcSubmitValidationStateStorageTest.php new file mode 100644 index 0000000..ae33102 --- /dev/null +++ b/tests/php/Unit/Checkout/Ajax/OpcSubmitValidationStateStorageTest.php @@ -0,0 +1,187 @@ +rows[42] = json_encode([ + 'opc' => [ + 'validation_errors' => ['legacy' => ['keep']], + ], + 'checksum' => 'abc', + ]); + + $state = [ + 'cart_id' => 42, + 'validation_errors' => [ + 'address' => [ + 'firstname' => ['This field is required.'], + ], + ], + 'form_errors' => [ + 'firstname' => ['This field is required.'], + ], + 'submitted_values' => [ + 'firstname' => '', + ], + ]; + + $storage = new TestableOnePageCheckoutSubmitValidationStateStorage( + $this->createContextWithCart(42), + $db + ); + + $storage->save($state); + + self::assertIsString($db->rows[42] ?? null); + $persistedData = json_decode($db->rows[42], true); + self::assertSame('abc', $persistedData['checksum'] ?? null); + self::assertSame(['legacy' => ['keep']], $persistedData['opc']['validation_errors'] ?? null); + self::assertSame($state, $persistedData['_opc_submit_validation_state'] ?? null); + } + + public function testConsumeReturnsStoredStateAndClearsOnlyModuleKey(): void + { + $db = new InMemoryCheckoutSessionDataDb(); + $state = [ + 'cart_id' => 42, + 'validation_errors' => [], + 'form_errors' => [ + 'firstname' => ['Required'], + ], + 'submitted_values' => [ + 'firstname' => '', + ], + ]; + $db->rows[42] = json_encode([ + '_opc_submit_validation_state' => $state, + 'checksum' => 'keep-me', + ]); + + $context = $this->createContextWithCart(42); + $storage = new TestableOnePageCheckoutSubmitValidationStateStorage($context, $db); + + self::assertSame($state, $storage->consume()); + + $persistedData = json_decode($db->rows[42], true); + self::assertSame(['checksum' => 'keep-me'], $persistedData); + self::assertSame('{"checksum":"keep-me"}', $context->cart->checkout_session_data); + self::assertSame([], $storage->consume()); + } + + public function testConsumeReturnsEmptyWhenNoStateExists(): void + { + $storage = new TestableOnePageCheckoutSubmitValidationStateStorage( + $this->createContextWithCart(42), + new InMemoryCheckoutSessionDataDb() + ); + + self::assertSame([], $storage->consume()); + } + + public function testDifferentCartDoesNotConsumeAnotherCartState(): void + { + $db = new InMemoryCheckoutSessionDataDb(); + $db->rows[42] = json_encode([ + '_opc_submit_validation_state' => [ + 'cart_id' => 42, + 'validation_errors' => [], + 'form_errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], + 'submitted_values' => [], + ], + ]); + + $storage = new TestableOnePageCheckoutSubmitValidationStateStorage( + $this->createContextWithCart(99), + $db + ); + + self::assertSame([], $storage->consume()); + self::assertArrayHasKey(42, $db->rows); + } + + private function createContextWithCart(int $cartId): \Context + { + $context = $this->createMock(\Context::class); + $context->cart = new \stdClass(); + $context->cart->id = $cartId; + + return $context; + } +} + +class InMemoryCheckoutSessionDataDb +{ + /** + * @var array + */ + public array $rows = []; + + public function getValue(string $sql): string + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + return $this->rows[$cartId] ?? ''; + } + + public function escape(string $value): string + { + return addslashes($value); + } + + public function execute(string $sql): bool + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + if (str_contains($sql, 'checkout_session_data = NULL')) { + unset($this->rows[$cartId]); + + return true; + } + + preg_match('/checkout_session_data = "(.*)" WHERE id_cart/s', $sql, $valueMatches); + $checkoutSessionData = isset($valueMatches[1]) ? stripslashes($valueMatches[1]) : ''; + + if ($checkoutSessionData === null || $checkoutSessionData === '') { + unset($this->rows[$cartId]); + + return true; + } + + $this->rows[$cartId] = $checkoutSessionData; + + return true; + } +} + +class TestableOnePageCheckoutSubmitValidationStateStorage extends OnePageCheckoutSubmitValidationStateStorage +{ + private object $db; + + public function __construct(\Context $context, object $db) + { + parent::__construct($context); + $this->db = $db; + } + + protected function getDb(): object + { + return $this->db; + } +} diff --git a/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php b/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php index 70a0d07..dcc52e6 100644 --- a/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php +++ b/tests/php/Unit/Checkout/CheckoutOnePageStepRequestPersistenceTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\Submit\OnePageCheckoutSubmitValidationStateStorage; use PrestaShop\Module\PsOnePageCheckout\Checkout\CheckoutOnePageStep; use PrestaShop\Module\PsOnePageCheckout\Form\OnePageCheckoutForm; use Symfony\Contracts\Translation\TranslatorInterface; @@ -34,11 +35,13 @@ class CheckoutOnePageStepRequestPersistenceTest extends TestCase private OnePageCheckoutForm|MockObject $opcForm; private \PaymentOptionsFinder|MockObject $paymentOptionsFinder; private \ConditionsToApproveFinder|MockObject $conditionsToApproveFinder; + private InMemoryCheckoutSessionDataDb $storageDb; private TestableCheckoutOnePageStep $step; protected function setUp(): void { $this->cart = $this->createMock(\Cart::class); + $this->cart->id = 42; $this->cart->method('isVirtualCart')->willReturn(false); $this->cart->method('getOrderTotal')->willReturn(10.0); @@ -94,13 +97,16 @@ public function write(): void $this->opcForm = $this->createMock(OnePageCheckoutForm::class); $this->paymentOptionsFinder = $this->createMock(\PaymentOptionsFinder::class); $this->conditionsToApproveFinder = $this->createMock(\ConditionsToApproveFinder::class); + $this->storageDb = new InMemoryCheckoutSessionDataDb(); $this->step = new TestableCheckoutOnePageStep( $this->context, $translator, $this->opcForm, $this->paymentOptionsFinder, - $this->conditionsToApproveFinder + $this->conditionsToApproveFinder, + null, + new TestableSubmitValidationStateStorage($this->context, $this->storageDb) ); $this->step->setMockProcess($this->checkoutProcess); } @@ -155,20 +161,22 @@ public function testPersistedValidationErrorsAreFlushedAfterFirstRestore(): void public function testStepRestoresLastFailedSubmitStateFromModuleStorage(): void { - $this->context->cookie->__set('opc_submit_validation_state', json_encode([ - 'validation_errors' => [ - 'payment' => [ - 'paymentMethod' => 'Please select a payment method.', + $this->context->cart->checkout_session_data = json_encode([ + '_opc_submit_validation_state' => [ + 'validation_errors' => [ + 'payment' => [ + 'paymentMethod' => 'Please select a payment method.', + ], + ], + 'form_errors' => [ + 'firstname' => ['The firstname field is required.'], + ], + 'submitted_values' => [ + 'firstname' => 'Ada', + 'use_same_address' => '0', ], ], - 'form_errors' => [ - 'firstname' => ['The firstname field is required.'], - ], - 'submitted_values' => [ - 'firstname' => 'Ada', - 'use_same_address' => '0', - ], - ])); + ]); $this->opcForm->expects($this->once()) ->method('restoreSubmissionState') @@ -193,7 +201,69 @@ public function testStepRestoresLastFailedSubmitStateFromModuleStorage(): void self::assertSame([ 'validation_errors' => [], ], $this->step->getDataToPersist()); - self::assertSame('', $this->context->cookie->__get('opc_submit_validation_state')); + self::assertSame('', $this->context->cart->checkout_session_data); + } + + public function testStepConsumesTopLevelSubmitErrorOnlyOnceAcrossReloads(): void + { + $this->context->cart->checkout_session_data = json_encode([ + '_opc_submit_validation_state' => [ + 'cart_id' => 42, + 'validation_errors' => [], + 'form_errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], + 'submitted_values' => [], + ], + ]); + + $this->opcForm->expects($this->once()) + ->method('restoreSubmissionState') + ->with([], ['' => ['One-page checkout is currently unavailable.']]) + ->willReturnSelf(); + + $this->step->handleRequest([]); + + self::assertSame('', $this->context->cart->checkout_session_data); + + $freshOpcForm = $this->createMock(OnePageCheckoutForm::class); + $freshOpcForm->expects($this->never())->method('restoreSubmissionState'); + + $freshStep = new TestableCheckoutOnePageStep( + $this->context, + $this->createConfiguredMock(TranslatorInterface::class, ['trans' => '']), + $freshOpcForm, + $this->paymentOptionsFinder, + $this->conditionsToApproveFinder, + null, + new TestableSubmitValidationStateStorage($this->context, $this->storageDb) + ); + $freshStep->setMockProcess($this->checkoutProcess); + + $freshStep->handleRequest([]); + + self::assertSame([], $freshStep->getValidationErrors()); + } + + public function testStepIgnoresSubmitStateFromAnotherCart(): void + { + $this->context->cart->checkout_session_data = json_encode([ + '_opc_submit_validation_state' => [ + 'cart_id' => 999, + 'validation_errors' => [], + 'form_errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], + 'submitted_values' => [], + ], + ]); + + $this->opcForm->expects($this->never())->method('restoreSubmissionState'); + + $this->step->handleRequest([]); + + self::assertSame([], $this->step->getValidationErrors()); + self::assertSame('', $this->context->cart->checkout_session_data); } public function testDeliveryOptionNotPersistedOnVirtualCart(): void @@ -227,3 +297,55 @@ public function testDeliveryOptionNotPersistedOnVirtualCart(): void self::assertTrue(true); } } + +class InMemoryCheckoutSessionDataDb +{ + /** + * @var array + */ + public array $rows = []; + + public function getValue(string $sql): string + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + return $this->rows[$cartId] ?? ''; + } + + public function escape(string $value): string + { + return addslashes($value); + } + + public function execute(string $sql): bool + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + if (str_contains($sql, 'checkout_session_data = NULL')) { + unset($this->rows[$cartId]); + $this->rows[$cartId] = ''; + + return true; + } + + preg_match('/checkout_session_data = "(.*)" WHERE id_cart/s', $sql, $valueMatches); + $this->rows[$cartId] = isset($valueMatches[1]) ? stripslashes($valueMatches[1]) : ''; + + return true; + } +} + +class TestableSubmitValidationStateStorage extends OnePageCheckoutSubmitValidationStateStorage +{ + public function __construct(\Context $context, private InMemoryCheckoutSessionDataDb $db) + { + parent::__construct($context); + } + + protected function getDb(): InMemoryCheckoutSessionDataDb + { + return $this->db; + } +} diff --git a/tests/php/Unit/Controller/OpcSubmitControllerTest.php b/tests/php/Unit/Controller/OpcSubmitControllerTest.php index 9daa944..6e14ffd 100644 --- a/tests/php/Unit/Controller/OpcSubmitControllerTest.php +++ b/tests/php/Unit/Controller/OpcSubmitControllerTest.php @@ -88,14 +88,83 @@ public function isOnePageCheckoutEnabled(): bool self::assertFalse($response['success']); self::assertTrue($response['reload']); } + + public function testHandleOpcSubmitPassesReloadErrorResponseToHandler(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $controller = new TestOpcSubmitController(); + $controller->module = new class extends \Ps_Onepagecheckout { + public function __construct() + { + } + + public function isOnePageCheckoutEnabled(): bool + { + return true; + } + }; + $controller->submitHandler = $this->getMockBuilder(OnePageCheckoutSubmitHandler::class) + ->disableOriginalConstructor() + ->onlyMethods(['handle']) + ->getMock(); + $controller->submitHandler + ->expects($this->once()) + ->method('handle') + ->with( + $this->isType('array'), + $this->callback(static function (array $response): bool { + return ($response['reload'] ?? false) === true + && ($response['errors'][''][0] ?? null) === 'One-page checkout is currently unavailable.'; + }) + ) + ->willThrowException(new \RuntimeException('submit exploded')); + + $response = $controller->callHandleOpcRequest(); + + self::assertFalse($response['success']); + self::assertTrue($response['reload']); + self::assertArrayHasKey('errors', $response); + self::assertSame( + ['One-page checkout is currently unavailable.'], + $response['errors'][''] + ); + } } class TestOpcSubmitController extends \Ps_OnepagecheckoutOpcSubmitModuleFrontController { public ?OnePageCheckoutSubmitHandler $submitHandler = null; + private InMemoryCheckoutSessionDataDb $storageDb; public function __construct() { + $this->context = new \Context(); + $this->context->cart = new \stdClass(); + $this->context->cart->id = 42; + $this->storageDb = new InMemoryCheckoutSessionDataDb(); + $this->context->cookie = new class { + public array $values = []; + + public function __set(string $key, string $value): void + { + $this->values[$key] = $value; + } + + public function __get(string $key): string + { + return $this->values[$key] ?? ''; + } + + public function __unset(string $key): void + { + unset($this->values[$key]); + } + + public function write(): void + { + } + }; } public function callHandleOpcRequest(): array @@ -103,6 +172,21 @@ public function callHandleOpcRequest(): array return $this->handleOpcRequest(); } + /** + * @return array|null + */ + public function getStoredValidationState(): ?array + { + $checkoutSessionData = json_decode((string) ($this->context->cart->checkout_session_data ?? ''), true); + if (!is_array($checkoutSessionData)) { + return null; + } + + $storedState = $checkoutSessionData['_opc_submit_validation_state'] ?? null; + + return is_array($storedState) ? $storedState : null; + } + protected function createSubmitHandler(): OnePageCheckoutSubmitHandler { if (!$this->submitHandler instanceof OnePageCheckoutSubmitHandler) { @@ -112,13 +196,73 @@ protected function createSubmitHandler(): OnePageCheckoutSubmitHandler return $this->submitHandler; } + protected function createSubmitValidationStateStorage(): \PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\Submit\OnePageCheckoutSubmitValidationStateStorage + { + return new TestableSubmitValidationStateStorage($this->context, $this->storageDb); + } + protected function buildTechnicalErrorResponse(): array { return [ 'success' => false, 'error' => 'technical-error', + 'errors' => [ + '' => ['One-page checkout is currently unavailable.'], + ], 'reload' => true, 'checkout_url' => '/commande', ]; } } + +class InMemoryCheckoutSessionDataDb +{ + /** + * @var array + */ + public array $rows = []; + + public function getValue(string $sql): string + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + return $this->rows[$cartId] ?? ''; + } + + public function escape(string $value): string + { + return addslashes($value); + } + + public function execute(string $sql): bool + { + preg_match('/id_cart = (\d+)/', $sql, $matches); + $cartId = isset($matches[1]) ? (int) $matches[1] : 0; + + if (str_contains($sql, 'checkout_session_data = NULL')) { + unset($this->rows[$cartId]); + + return true; + } + + preg_match('/checkout_session_data = "(.*)" WHERE id_cart/s', $sql, $valueMatches); + $checkoutSessionData = isset($valueMatches[1]) ? stripslashes($valueMatches[1]) : ''; + $this->rows[$cartId] = $checkoutSessionData; + + return true; + } +} + +class TestableSubmitValidationStateStorage extends \PrestaShop\Module\PsOnePageCheckout\Checkout\Ajax\Submit\OnePageCheckoutSubmitValidationStateStorage +{ + public function __construct(\Context $context, private InMemoryCheckoutSessionDataDb $db) + { + parent::__construct($context); + } + + protected function getDb(): InMemoryCheckoutSessionDataDb + { + return $this->db; + } +} diff --git a/tests/php/Unit/Form/OnePageCheckoutFormTest.php b/tests/php/Unit/Form/OnePageCheckoutFormTest.php index 04ba4ae..2fff9ec 100644 --- a/tests/php/Unit/Form/OnePageCheckoutFormTest.php +++ b/tests/php/Unit/Form/OnePageCheckoutFormTest.php @@ -234,6 +234,62 @@ public function testItReturnsPersisterErrorsWhenGuestSaveFails(): void 'communication_channel' => 'email', ]))); self::assertNull($this->context->updatedCustomer); + self::assertSame(['Unable to save guest customer'], $form->getField('email')->getErrors()); + } + + public function testSubmitProjectsCustomerPersisterErrorsOntoFormFields(): void + { + $form = $this->createSubmitForm(); + $form->forceValidateResult(true); + + $this->context->cart = new LightweightCart(); + $this->context->cart->id = -1; + + $this->customerPersister + ->expects($this->once()) + ->method('save') + ->willReturn(false) + ; + $this->customerPersister + ->expects($this->once()) + ->method('getErrors') + ->willReturn([ + 'email' => ['Unable to save customer'], + ]) + ; + $this->addressPersister + ->expects($this->never()) + ->method('save') + ; + + $form->fillWith($this->withDefaultCountry([ + 'email' => 'guest.submit@example.com', + 'firstname' => 'John', + 'lastname' => 'Doe', + 'address1' => '1 Delivery street', + 'city' => 'Paris', + 'postcode' => '75001', + 'use_same_address' => '1', + 'psgdpr_privacy' => '1', + 'compliance_terms' => '1', + 'communication_channel' => 'email', + ])); + + self::assertFalse($form->submit()); + self::assertSame(['Unable to save customer'], $form->getField('email')->getErrors()); + + $persistedState = $form->buildPersistedSubmissionState( + ['email' => 'guest.submit@example.com'], + ['address' => ['email' => ['Unable to save customer']]] + ); + + self::assertSame( + [ + '' => [], + 'email' => ['Unable to save customer'], + ], + $persistedState['form_errors'] + ); } public function testItDoesNotCreateGuestCustomerWhenRequiredRadioConsentIsMissing(): void @@ -434,6 +490,147 @@ public function testItSeparatesTemplateVariablesByBusinessOrigin(): void self::assertSame('id_address_invoice', $templateVariables['invoiceMetaFields']['id_address_invoice']['name']); } + public function testRestoreSubmissionStateRestoresErrorsOnModuleCustomerFieldsByInternalKey(): void + { + $customerProbeText = (new \FormField()) + ->setName('customer_probe_text') + ->setType('text'); + + $formatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry', 'getFieldGroup']) + ->getMock() + ; + $formatter + ->method('getFormat') + ->willReturn([ + 'opce2efixtures_customer_probe_text' => $customerProbeText, + ]) + ; + $country = CheckoutTestFixtures::country(); + $country->id = self::DEFAULT_COUNTRY_ID; + $formatter->method('getCountry')->willReturn($country); + $formatter->method('setCountry')->willReturnSelf(); + $formatter->method('setInvoiceCountry')->willReturnSelf(); + $formatter + ->method('getFieldGroup') + ->willReturnCallback(static function (string $key): ?string { + return $key === 'opce2efixtures_customer_probe_text' + ? OnePageCheckoutFormatter::FIELD_GROUP_CUSTOMER + : null; + }) + ; + + $form = new TestableOnePageCheckoutForm( + $this->createMock(\Smarty::class), + $this->context, + CheckoutTestFixtures::language(1), + $this->translator, + $formatter, + $this->customerPersister, + $this->addressPersister + ); + + $form->restoreSubmissionState( + ['customer_probe_text' => ''], + ['opce2efixtures_customer_probe_text' => ['Fixture customer note is required.']] + ); + + self::assertSame( + ['Fixture customer note is required.'], + $form->getField('opce2efixtures_customer_probe_text')->getErrors() + ); + } + + public function testRestoreSubmissionStateRestoresErrorsOnNativeFieldsByInternalKey(): void + { + $form = $this->createSubmitForm(); + + $form->restoreSubmissionState( + ['firstname' => ''], + ['firstname' => ['The firstname field is required.']] + ); + + self::assertSame( + ['The firstname field is required.'], + $form->getField('firstname')->getErrors() + ); + } + + public function testBuildPersistedSubmissionStateUsesInternalFieldKeysForCustomFields(): void + { + $customerProbeText = (new \FormField()) + ->setName('customer_probe_text') + ->setType('text') + ->addError('Fixture customer note is required.'); + + $firstname = (new \FormField()) + ->setName('firstname') + ->setType('text') + ->addError('The firstname field is required.'); + + $formatter = $this->getMockBuilder(OnePageCheckoutFormatter::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFormat', 'getCountry', 'setCountry', 'setInvoiceCountry', 'getFieldGroup']) + ->getMock() + ; + $formatter + ->method('getFormat') + ->willReturn([ + 'firstname' => $firstname, + 'opce2efixtures_customer_probe_text' => $customerProbeText, + ]) + ; + $country = CheckoutTestFixtures::country(); + $country->id = self::DEFAULT_COUNTRY_ID; + $formatter->method('getCountry')->willReturn($country); + $formatter->method('setCountry')->willReturnSelf(); + $formatter->method('setInvoiceCountry')->willReturnSelf(); + $formatter + ->method('getFieldGroup') + ->willReturnCallback(static function (string $key): ?string { + return $key === 'opce2efixtures_customer_probe_text' + ? OnePageCheckoutFormatter::FIELD_GROUP_CUSTOMER + : null; + }) + ; + + $form = new TestableOnePageCheckoutForm( + $this->createMock(\Smarty::class), + $this->context, + CheckoutTestFixtures::language(1), + $this->translator, + $formatter, + $this->customerPersister, + $this->addressPersister + ); + $form->fillWith([ + 'firstname' => '', + 'customer_probe_text' => '', + ]); + + $persistedState = $form->buildPersistedSubmissionState( + [ + 'firstname' => '', + 'customer_probe_text' => '', + ], + [ + 'address' => [ + 'firstname' => ['The firstname field is required.'], + ], + ] + ); + + self::assertSame( + [ + '' => [], + 'firstname' => ['The firstname field is required.'], + 'opce2efixtures_customer_probe_text' => ['Fixture customer note is required.'], + ], + $persistedState['form_errors'] + ); + } + public function testSubmitPersistsDeliveryAndInvoiceAddressesWhenUseSameAddressIsDisabled(): void { $form = $this->createSubmitForm(); diff --git a/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php index 452cdbe..b6b3911 100644 --- a/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php +++ b/tests/php/Unit/Js/OpcAddressModalSpe54ContractTest.php @@ -17,15 +17,25 @@ public function testAddressModalScriptReferencesExpectedOpcEndpoints(): void self::assertStringContainsString('saveAddress', $script); self::assertStringContainsString('deleteAddress', $script); self::assertStringContainsString('updatedOpcAddressForm', $script); + self::assertStringContainsString('normalizeErrorEventResponse', $script); + self::assertStringContainsString('retry-addresses', $script); + self::assertStringContainsString('opc-delivery-address-loader', $script); + self::assertStringContainsString('opc-billing-address-loader', $script); + self::assertStringContainsString('refreshAddressLists', $script); self::assertStringContainsString("$(document).on('input change', MODAL_FIELD_SELECTOR", $script); self::assertStringContainsString('const $trigger = $(event.relatedTarget);', $script); self::assertStringContainsString("$(document).on('shown.bs.modal', MODAL_SELECTOR", $script); self::assertStringContainsString('setModalFieldsDisabled($modal, false);', $script); self::assertStringContainsString('const $modal = $(`#${DELETE_CONFIRM_MODAL_ID}`);', $script); + self::assertStringNotContainsString('ADDRESSES_FEEDBACK_SELECTOR', $script); + self::assertStringNotContainsString('captureAddressListMarkup', $script); + self::assertStringNotContainsString('restoreAddressListMarkup', $script); self::assertStringNotContainsString('ensureDeleteConfirmModal', $script); self::assertStringNotContainsString('syncDeliveryMethodsContainerAddressId', $script); self::assertStringNotContainsString('syncHiddenAddressIdsFromSavedSelections', $script); self::assertStringNotContainsString('recoverStaleSavedAddressSelections', $script); + self::assertStringNotContainsString('buildAddressesRefreshState', $script); + self::assertStringNotContainsString('opc-template-addresses-loader', $script); self::assertStringNotContainsString('`${MODAL_SELECTOR} input, ${MODAL_SELECTOR} select, ${MODAL_SELECTOR} textarea`', $script); self::assertStringNotContainsString('window.confirm', $script); } diff --git a/tests/php/Unit/Js/OpcJavascriptContractTest.php b/tests/php/Unit/Js/OpcJavascriptContractTest.php index 1e4f35a..04aab73 100644 --- a/tests/php/Unit/Js/OpcJavascriptContractTest.php +++ b/tests/php/Unit/Js/OpcJavascriptContractTest.php @@ -31,9 +31,9 @@ public function testSharedOpcContractsExposeCentralizedEventsAndSelectors(): voi public function testSubmitScriptEmitsHistoricalFinalSubmitEvent(): void { $script = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-submit.js'); - self::assertStringContainsString("import OPC_EVENTS from './events';", $script); + self::assertStringContainsString("import {OPC_EVENTS} from './events';", $script); self::assertStringContainsString("import OPC_SELECTORS from './selectors';", $script); - self::assertStringContainsString("import {getConfiguredOpcUrl, normalizeErrorResponse, updatePayAmount} from './runtime/opc-runtime';", $script); + self::assertStringContainsString("import {getConfiguredOpcUrl, normalizeErrorEventResponse, getConfiguredOpcMessage, updatePayAmount} from './runtime/opc-runtime';", $script); self::assertStringContainsString('prestashop.emit(OPC_EVENTS.opcFinalSubmitStarted)', $script); self::assertStringContainsString('const OPC_FORM_ID_SELECTOR = OPC_SELECTORS.opc.form;', $script); self::assertStringContainsString('const PAY_BUTTON_SELECTOR = OPC_SELECTORS.opc.payButton;', $script); @@ -55,7 +55,7 @@ public function testGuestInitScriptUsesModuleRuntimeContract(): void { $script = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-guest-init.js'); self::assertStringContainsString('import {getConfiguredOpcUrl}', $script); - self::assertStringContainsString("import OPC_EVENTS from './events';", $script); + self::assertStringContainsString("import {OPC_EVENTS} from './events';", $script); self::assertStringContainsString("import OPC_SELECTORS from './selectors';", $script); self::assertStringContainsString('getConfiguredOpcUrl(MODULE_GUEST_INIT_URL_KEY)', $script); self::assertStringContainsString('prestashop.on(OPC_EVENTS.opcFinalSubmitStarted', $script); @@ -67,11 +67,32 @@ public function testAddressFormScriptUsesModuleRuntimeContract(): void { $script = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-address.js'); self::assertStringContainsString('import {getConfiguredOpcUrl}', $script); - self::assertStringContainsString("import OPC_EVENTS from './events';", $script); + self::assertStringContainsString("import {OPC_EVENTS} from './events';", $script); + self::assertStringContainsString("import {emitAddressUpdate} from './address-events';", $script); self::assertStringContainsString("import OPC_SELECTORS from './selectors';", $script); self::assertStringContainsString('getConfiguredOpcUrl(MODULE_ADDRESS_FORM_URL_KEY)', $script); - self::assertStringContainsString('prestashop.emit(OPC_EVENTS.opcDeliveryAddressUpdated', $script); - self::assertStringContainsString('prestashop.emit(OPC_EVENTS.opcBillingAddressUpdated', $script); + self::assertStringContainsString("emitAddressUpdate('delivery'", $script); + self::assertStringContainsString("emitAddressUpdate('billing'", $script); + } + + public function testRuntimeAndDynamicScriptsDelegateErrorDisplayToThemeHandler(): void + { + $runtimeScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/runtime/opc-runtime.js'); + $carrierScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-carrier-select.js'); + $paymentScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-payment-select.js'); + $addressModalScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-address-modal.js'); + + self::assertStringContainsString('normalizeErrorEventResponse', $runtimeScript); + self::assertStringNotContainsString('collectNestedErrorMessages', $runtimeScript); + self::assertStringNotContainsString('showRuntimeNotification', $runtimeScript); + self::assertStringNotContainsString('appendFallbackAlert', $runtimeScript); + self::assertStringContainsString('normalizeErrorEventResponse', $carrierScript); + self::assertStringContainsString('normalizeErrorEventResponse', $paymentScript); + self::assertStringContainsString('normalizeErrorEventResponse', $addressModalScript); + self::assertStringNotContainsString('showRuntimeErrorNotification', $carrierScript); + self::assertStringNotContainsString('showRuntimeErrorNotification', $paymentScript); + self::assertStringNotContainsString('showRuntimeErrorNotification', $addressModalScript); + self::assertStringNotContainsString('showRuntimeNotification', $addressModalScript); } public function testPaymentScriptsUseModuleRuntimeContracts(): void @@ -79,7 +100,7 @@ public function testPaymentScriptsUseModuleRuntimeContracts(): void $listScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-payment-list.js'); $selectScript = (string) file_get_contents(_PS_ROOT_DIR_ . '/modules/ps_onepagecheckout/views/js/opc-payment-select.js'); - self::assertStringContainsString("import OPC_EVENTS from './events';", $listScript); + self::assertStringContainsString("import {CORE_EVENTS, OPC_EVENTS} from './events';", $listScript); self::assertStringContainsString("import OPC_SELECTORS from './selectors';", $listScript); self::assertStringContainsString('paymentMethods', $listScript); self::assertStringContainsString("prestashop.emit('handleError'", $listScript); @@ -90,7 +111,7 @@ public function testPaymentScriptsUseModuleRuntimeContracts(): void self::assertStringContainsString('fetchGeneration', $listScript); self::assertStringNotContainsString("prestashop.on('opcCarriersUpdated'", $listScript); self::assertStringNotContainsString("prestashop.on('opcDeliveryAddressUpdated'", $listScript); - self::assertStringContainsString("import OPC_EVENTS from './events';", $selectScript); + self::assertStringContainsString("import {OPC_EVENTS} from './events';", $selectScript); self::assertStringContainsString("import OPC_SELECTORS from './selectors';", $selectScript); self::assertStringContainsString('selectPayment', $selectScript); self::assertStringContainsString('payment_selection_key', $selectScript); diff --git a/tests/php/Unit/Module/PsOnepagecheckoutModuleTest.php b/tests/php/Unit/Module/PsOnepagecheckoutModuleTest.php index 8bbb8b5..c31e2fe 100644 --- a/tests/php/Unit/Module/PsOnepagecheckoutModuleTest.php +++ b/tests/php/Unit/Module/PsOnepagecheckoutModuleTest.php @@ -163,6 +163,10 @@ public function testHookActionFrontControllerSetMediaAssignsFlagAndRegistersAsse self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['paymentMethods']); self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['selectPayment']); self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['urls']['opcSubmit']); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['messages']['selectCarrierFailed'] ?? null); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['messages']['selectPaymentFailed'] ?? null); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['messages']['statesLoadFailed'] ?? null); + self::assertNotEmpty($module->javascriptDefinitions[0]['ps_onepagecheckout']['messages']['refreshAddressesFailed'] ?? null); } public function testHookActionFrontControllerSetMediaAssignsFlagAndSkipsAssetsWhenDisabled(): void @@ -294,6 +298,14 @@ protected function registerOpcJavascriptAssets(): void ++$this->registeredJavascriptAssetsCalls; } + /** + * @param array $parameters + */ + protected function trans($id, array $parameters = [], $domain = null, $locale = null): string + { + return (string) $id; + } + protected function disableInParent(bool $forceAll): bool { $this->disableInParentCalls[] = $forceAll; diff --git a/tests/php/bootstrap-unit.php b/tests/php/bootstrap-unit.php index 952b6d9..f64e44d 100644 --- a/tests/php/bootstrap-unit.php +++ b/tests/php/bootstrap-unit.php @@ -8,6 +8,7 @@ require __DIR__ . '/../../../../tests/Unit/bootstrap.php'; require_once __DIR__ . '/Mocks/bootstrap.php'; +require_once __DIR__ . '/Fixtures/CheckoutProcessProviderInterfaceStub.php'; require_once __DIR__ . '/Fixtures/LegacyFormStubs.php'; require_once __DIR__ . '/Fixtures/CheckoutTestFixtures.php'; require __DIR__ . '/bootstrap-autoload.php'; diff --git a/tests/php/phpstan/stubs/Cookie.stub b/tests/php/phpstan/stubs/Cookie.stub index d78ee4d..8977a21 100644 --- a/tests/php/phpstan/stubs/Cookie.stub +++ b/tests/php/phpstan/stubs/Cookie.stub @@ -12,6 +12,7 @@ * @property bool $is_guest * @property string $psopc_bo_configuration_saved * @property string $psopc_bo_maintenance_enabled + * @property string $opc_temp_delivery_address */ class Cookie { diff --git a/views/js/address-events.js b/views/js/address-events.js new file mode 100644 index 0000000..2147bd6 --- /dev/null +++ b/views/js/address-events.js @@ -0,0 +1,14 @@ +import {OPC_EVENTS} from './events'; + +export function emitAddressUpdate(type, payload = {}) { + const prestashop = window.prestashop || {}; + + if (type === 'delivery') { + prestashop.emit(OPC_EVENTS.opcDeliveryAddressUpdated, payload); + return; + } + + if (type === 'billing') { + prestashop.emit(OPC_EVENTS.opcBillingAddressUpdated, payload); + } +} diff --git a/views/js/events.js b/views/js/events.js index 8cc7e25..6088acd 100644 --- a/views/js/events.js +++ b/views/js/events.js @@ -1,4 +1,8 @@ -const OPC_EVENTS = { +export const CORE_EVENTS = { + updatedCart: 'updatedCart', +}; + +export const OPC_EVENTS = { opcCarrierSelected: 'opcCarrierSelected', opcCarriersUpdated: 'opcCarriersUpdated', opcCarriersFailed: 'opcCarriersFailed', @@ -8,6 +12,7 @@ const OPC_EVENTS = { opcPaymentMethodsFailed: 'opcPaymentMethodsFailed', opcPaymentMethodSelected: 'opcPaymentMethodSelected', opcGuestInitSuccess: 'opcGuestInitSuccess', + opcGuestInitFailed: 'opcGuestInitFailed', opcFinalSubmitStarted: 'opcFinalSubmitStarted', opcFormValidated: 'opcFormValidated', opcBillingSectionToggled: 'opcBillingSectionToggled', @@ -21,6 +26,5 @@ const OPC_EVENTS = { updatedOpcAddressForm: 'updatedOpcAddressForm', opcCarriersRetry: 'opcCarriersRetry', opcPaymentMethodsRetry: 'opcPaymentMethodsRetry', + opcGiftWrapping: 'opcGiftWrapping', }; - -export default OPC_EVENTS; diff --git a/views/js/opc-address-modal.js b/views/js/opc-address-modal.js index d10eea0..86f6199 100644 --- a/views/js/opc-address-modal.js +++ b/views/js/opc-address-modal.js @@ -1,6 +1,11 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; +import {emitAddressUpdate} from './address-events'; import OPC_SELECTORS from './selectors'; -import {getConfiguredOpcUrl} from './runtime/opc-runtime'; +import { + getConfiguredOpcMessage, + getConfiguredOpcUrl, + normalizeErrorEventResponse, +} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -73,6 +78,27 @@ const DELETE_CONFIRM_MODAL_ID = 'opc-delete-address-confirm-modal'; const RESTORE_SELECTION_ID_ATTRIBUTE = 'data-opc-restore-address-id'; const RESTORE_SELECTION_RADIO_NAME_ATTRIBUTE = 'data-opc-restore-radio-name'; const SKIP_RESTORE_SELECTION_ATTRIBUTE = 'data-opc-skip-restore-selection'; +const ADDRESS_LIST_CONFIG = { + delivery: { + listSelector: OPC_SELECTORS.opc.deliveryList, + loaderTemplateSelector: '#opc-delivery-address-loader', + errorTemplateSelector: '#opc-delivery-address-error', + }, + billing: { + listSelector: OPC_SELECTORS.opc.billingList, + loaderTemplateSelector: '#opc-billing-address-loader', + errorTemplateSelector: '#opc-billing-address-error', + }, +}; +let pendingAddressListRefreshOptions = null; +let addressListGeneration = 0; + +function emitHandleError(eventType, response, fallbackMessage = '') { + prestashop.emit('handleError', { + eventType, + resp: normalizeErrorEventResponse(response, fallbackMessage), + }); +} function isNonSubmittableField($field) { return $field.is(':button, [type="button"], [type="submit"], [type="reset"], [type="image"], [type="file"]'); @@ -493,12 +519,10 @@ function refreshStates($modal, countryId, selectedStateId) { updateStateFieldUi($modal, response || {}, selectedStateId); updateModalSaveState($modal); }).fail((jqXHR) => { + const fallbackMessage = getConfiguredOpcMessage('statesLoadFailed', 'Unable to load states.'); updateStateFieldUi($modal, {hasStates: false, states: []}, ''); updateModalSaveState($modal); - prestashop.emit('handleError', { - eventType: 'opcAddressStates', - resp: jqXHR.responseJSON || {errors: {'': ['Unable to load states.']}}, - }); + emitHandleError('opcAddressStates', jqXHR && jqXHR.responseJSON, fallbackMessage); }); } @@ -551,6 +575,8 @@ function renderValidationErrors($modal, errors) { return; } + let $firstInvalidField = $(); + Object.entries(errors).forEach(([fieldName, fieldErrors]) => { const messages = Array.isArray(fieldErrors) ? fieldErrors.filter(Boolean) : []; if (messages.length === 0) { @@ -566,8 +592,17 @@ function renderValidationErrors($modal, errors) { return; } - appendFieldError(getModalField($modal, fieldName), messages[0]); + const $field = getModalField($modal, fieldName); + appendFieldError($field, messages[0]); + + if (!$firstInvalidField.length && $field.length) { + $firstInvalidField = $field; + } }); + + if ($firstInvalidField.length) { + $firstInvalidField.trigger('focus'); + } } function getAddressListSelectorForRadioName(radioName) { @@ -655,26 +690,60 @@ function restoreRememberedSelection($modal) { syncAddressItemStyles($radio.closest(OPC_SELECTORS.opc.addressItem).parent()); } -function showSuccessMessage(message) { - const normalizedMessage = String(message || '').trim(); - if (normalizedMessage === '') { - return; +function getTemplateHtml(templateSelector) { + const template = document.querySelector(templateSelector); + + return template ? template.innerHTML : ''; +} + +function getAddressListConfig(listType) { + if (!Object.prototype.hasOwnProperty.call(ADDRESS_LIST_CONFIG, listType)) { + return null; } - if (window.Theme && window.Theme.components && typeof window.Theme.components.useToast === 'function') { - const toast = window.Theme.components.useToast(normalizedMessage, {type: 'success'}); + return ADDRESS_LIST_CONFIG[listType]; +} + +function getAddressListTypes(options = {}) { + const requestedListTypes = Array.isArray(options.listTypes) && options.listTypes.length > 0 + ? options.listTypes + : Object.keys(ADDRESS_LIST_CONFIG); + + const matchedListTypes = requestedListTypes + .map((listType) => ({ + listType, + config: getAddressListConfig(listType), + })) + .filter(({config}) => config !== null && $(config.listSelector).length > 0); + + const visibleListTypes = matchedListTypes + .filter(({config}) => $(config.listSelector).is(':visible')) + .map(({listType}) => listType); + + return visibleListTypes.length > 0 + ? visibleListTypes + : matchedListTypes.map(({listType}) => listType); +} + +function renderAddressListsState(listTypes, templateKey) { + listTypes.forEach((listType) => { + const config = getAddressListConfig(listType); - if (toast && typeof toast.show === 'function') { - toast.show(); + if (!config) { + return; } - return; - } + const $list = $(config.listSelector); + const templateHtml = getTemplateHtml(config[templateKey]); - $('body').append($('
', { - class: 'alert alert-success', - text: normalizedMessage, - })); + if ($list.length && templateHtml) { + $list.html(templateHtml); + } + }); +} + +function getListTypesForAddressType(addressType) { + return addressType === 'invoice' ? ['billing'] : ['delivery']; } function serializeModalFields($modal) { @@ -742,6 +811,25 @@ function hideModal($modal) { } } +function syncAddressItemStyles($scope) { + if (!$scope.length) { + return; + } + + $scope.find(OPC_SELECTORS.opc.addressItem).each((_, item) => { + const $item = $(item); + const isSelected = $item.find(OPC_SELECTORS.opc.addressRadio).first().is(':checked'); + + $item.toggleClass('border-primary selected z-1', isSelected); + $item.find(OPC_SELECTORS.opc.addressLabel).first().toggleClass('fw-semibold', isSelected); + }); +} + +function syncAllSavedAddressItemStyles() { + syncAddressItemStyles($(OPC_SELECTORS.opc.deliveryList)); + syncAddressItemStyles($(OPC_SELECTORS.opc.billingList)); +} + function refreshAddressesSection(options = {}) { const addressFormUrl = getConfiguredOpcUrl(URL_KEYS.addressForm); const $addressForm = $(OPC_ADDRESSES_SECTION_SELECTOR).first(); @@ -779,44 +867,27 @@ function refreshAddressesSection(options = {}) { setAddressSectionFieldValue($addressForm, BILLING_SECTION_SELECTOR, BILLING_FIELDS_SELECTOR, 'invoice_id_country', payload.invoice_id_country); } + pendingAddressListRefreshOptions = null; syncAllSavedAddressItemStyles(); prestashop.emit(OPC_EVENTS.updatedOpcAddressForm, {target: $addressForm, resp: response}); - prestashop.emit(OPC_EVENTS.opcDeliveryAddressUpdated, {resp: response}); - prestashop.emit(OPC_EVENTS.opcBillingAddressUpdated, {resp: response}); - }); -} -function syncAddressItemStyles($scope) { - if (!$scope.length) { - return; - } - - $scope.find(OPC_SELECTORS.opc.addressItem).each((_, item) => { - const $item = $(item); - const isSelected = $item.find(OPC_SELECTORS.opc.addressRadio).first().is(':checked'); + if (options.listTypes?.includes('delivery')) { + emitAddressUpdate('delivery', {resp: response}); + return; + } - $item.toggleClass('border-primary selected z-1', isSelected); - $item.find(OPC_SELECTORS.opc.addressLabel).first().toggleClass('fw-semibold', isSelected); + if (options.listTypes?.includes('billing')) { + emitAddressUpdate('billing', {resp: response}); + } }); } -function syncAllSavedAddressItemStyles() { - syncAddressItemStyles($(OPC_SELECTORS.opc.deliveryList)); - syncAddressItemStyles($(OPC_SELECTORS.opc.billingList)); +function renderAddressListsLoadingState(listTypes) { + renderAddressListsState(listTypes, 'loaderTemplateSelector'); } -function renderAddressListsLoadingState() { - [ - [OPC_SELECTORS.opc.deliveryList, '#opc-delivery-address-loader'], - [OPC_SELECTORS.opc.billingList, '#opc-billing-address-loader'], - ].forEach(([listSelector, templateSelector]) => { - const $list = $(listSelector); - const templateHtml = $(templateSelector).html(); - - if ($list.length && templateHtml) { - $list.html(templateHtml); - } - }); +function renderAddressListsErrorState(listTypes) { + renderAddressListsState(listTypes, 'errorTemplateSelector'); } function applyAddressListsResponse(response, options = {}) { @@ -860,17 +931,32 @@ function applyAddressListsResponse(response, options = {}) { function refreshAddressLists(options = {}) { const addressesListUrl = getConfiguredOpcUrl(URL_KEYS.addressesList); + const listTypes = getAddressListTypes(options); + const fallbackMessage = getConfiguredOpcMessage('refreshAddressesFailed', 'Unable to refresh addresses.'); + const generation = ++addressListGeneration; + + pendingAddressListRefreshOptions = { + ...options, + listTypes, + }; if (!addressesListUrl) { return refreshAddressesSection(options); } - renderAddressListsLoadingState(); + renderAddressListsLoadingState(listTypes); return $.post(addressesListUrl) .then((response) => { + if (generation !== addressListGeneration) { + return response; + } + if (!response || response.success === false || typeof response.address_count === 'undefined') { - return refreshAddressesSection(options); + renderAddressListsErrorState(listTypes); + emitHandleError('opcAddressesList', response, fallbackMessage); + + return response; } const addressCount = parseInt(response.address_count, 10) || 0; @@ -879,14 +965,18 @@ function refreshAddressLists(options = {}) { return refreshAddressesSection(options); } + pendingAddressListRefreshOptions = null; applyAddressListsResponse(response, options); return response; }) .fail((jqXHR) => { - prestashop.emit('handleError', {eventType: 'opcAddressesList', resp: jqXHR.responseJSON || {}}); + if (generation !== addressListGeneration) { + return; + } - return refreshAddressesSection(options); + renderAddressListsErrorState(listTypes); + emitHandleError('opcAddressesList', jqXHR && jqXHR.responseJSON, fallbackMessage); }); } @@ -1033,6 +1123,11 @@ $(document).on('change', OPC_SELECTORS.opc.addressRadio, (event) => { } }); +$(document).on('click', '[data-opc-action="retry-addresses"]', (event) => { + event.preventDefault(); + refreshAddressLists(pendingAddressListRefreshOptions || {}); +}); + $(document).on('click', MODAL_SAVE_SELECTOR, (event) => { event.preventDefault(); @@ -1041,7 +1136,11 @@ $(document).on('click', MODAL_SAVE_SELECTOR, (event) => { const $modal = $button.closest(MODAL_SELECTOR); if (!saveAddressUrl || !$modal.length) { - prestashop.emit('handleError', {eventType: 'opcSaveAddress', resp: {errors: {'': ['Missing OPC save address URL.']}}}); + emitHandleError( + 'opcSaveAddress', + null, + getConfiguredOpcMessage('missingSaveAddressUrl', 'Missing OPC save address URL.') + ); return; } @@ -1058,8 +1157,15 @@ $(document).on('click', MODAL_SAVE_SELECTOR, (event) => { $.post(saveAddressUrl, payload) .done((response) => { if (!response || response.success === false) { - renderValidationErrors($modal, response && response.errors ? response.errors : {}); - prestashop.emit('handleError', {eventType: 'opcSaveAddress', resp: response || {}}); + if (response && response.errors) { + renderValidationErrors($modal, response.errors); + } + + emitHandleError( + 'opcSaveAddress', + response, + getConfiguredOpcMessage('saveAddressFailed', 'Unable to save address.') + ); return; } @@ -1068,13 +1174,17 @@ $(document).on('click', MODAL_SAVE_SELECTOR, (event) => { $modal.attr(SKIP_RESTORE_SELECTION_ATTRIBUTE, '1'); hideModal($modal); refreshAddressLists({ + listTypes: getListTypesForAddressType(addressType), refreshDeliverySelection: addressType === 'delivery', refreshBillingSelection: addressType === 'invoice', }); - showSuccessMessage(response.message || ''); }) .fail((jqXHR) => { - prestashop.emit('handleError', {eventType: 'opcSaveAddress', resp: jqXHR.responseJSON || {}}); + emitHandleError( + 'opcSaveAddress', + jqXHR && jqXHR.responseJSON, + getConfiguredOpcMessage('saveAddressFailed', 'Unable to save address.') + ); }) .always(() => { $button.prop('disabled', false).text(initialText); @@ -1088,7 +1198,11 @@ $(document).on('click', '.js-delete-address', (event) => { const $button = $(event.currentTarget); if (!deleteAddressUrl || !$button.length) { - prestashop.emit('handleError', {eventType: 'opcDeleteAddress', resp: {errors: {'': ['Missing OPC delete address URL.']}}}); + emitHandleError( + 'opcDeleteAddress', + null, + getConfiguredOpcMessage('missingDeleteAddressUrl', 'Missing OPC delete address URL.') + ); return; } @@ -1098,6 +1212,7 @@ $(document).on('click', '.js-delete-address', (event) => { return; } + const addressType = String($button.attr('data-address-type') || 'delivery'); $button.prop('disabled', true); $.post(deleteAddressUrl, { @@ -1105,19 +1220,27 @@ $(document).on('click', '.js-delete-address', (event) => { }) .done((response) => { if (!response || response.success === false) { - prestashop.emit('handleError', {eventType: 'opcDeleteAddress', resp: response || {}}); + emitHandleError( + 'opcDeleteAddress', + response, + getConfiguredOpcMessage('deleteAddressFailed', 'Unable to delete address.') + ); $button.prop('disabled', false); return; } refreshAddressLists({ + listTypes: getListTypesForAddressType(addressType), resetInlineAddressState: true, }); - showSuccessMessage(response.message || ''); }) .fail((jqXHR) => { - prestashop.emit('handleError', {eventType: 'opcDeleteAddress', resp: jqXHR.responseJSON || {}}); + emitHandleError( + 'opcDeleteAddress', + jqXHR && jqXHR.responseJSON, + getConfiguredOpcMessage('deleteAddressFailed', 'Unable to delete address.') + ); $button.prop('disabled', false); }); }); diff --git a/views/js/opc-address.js b/views/js/opc-address.js index 5209da9..0ac4671 100644 --- a/views/js/opc-address.js +++ b/views/js/opc-address.js @@ -1,6 +1,9 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; +import {emitAddressUpdate} from './address-events'; import OPC_SELECTORS from './selectors'; +import {getConfiguredOpcMessage} from './runtime/opc-runtime'; import {getConfiguredOpcUrl} from './runtime/opc-runtime'; +import {normalizeErrorEventResponse} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -39,6 +42,7 @@ const BILLING_FIELDS_SELECTOR = OPC_SELECTORS.opc.billingFields; const ADDRESS_MODAL_SELECTOR = OPC_SELECTORS.modals.address; const SAME_ADDRESS_SELECTOR = '[name="use_same_address"]'; const DISABLED_BY_SAME_ADDRESS_ATTRIBUTE = 'data-opc-disabled-by-same-address'; +let addressFormGeneration = 0; const SERVER_MANAGED_FIELDS = new Set([ 'id_country', @@ -342,6 +346,14 @@ function bindBillingToggleListener(selectors) { syncBillingSectionConstraints(addressContainer, useSameAddress); + // When "use same address" is enabled again, billing must stop using its + // previous country (for example US) and go back to the delivery country + // so invoice-based payment filtering is recalculated from delivery. + if (useSameAddress) { + seedBillingFromDelivery(addressContainer); + return; + } + if (!useSameAddress && !hasSeparateBillingDraft(addressContainer)) { seedBillingFromDelivery(addressContainer); } @@ -400,21 +412,30 @@ function refreshOpcAddressFormForCountryChange(target, selectors) { const formFieldsSelector = `${selectors.address} input, ${selectors.address} select, ${selectors.address} textarea`; const addressFormUrl = getConfiguredOpcUrl(MODULE_ADDRESS_FORM_URL_KEY); + const fallbackMessage = getConfiguredOpcMessage('missingAddressFormUrl', 'Unable to refresh addresses.'); if (addressFormUrl === '') { prestashop.emit('handleError', { eventType: 'updateOpcAddressForm', - resp: {errors: {'': ['Missing OPC address form URL.']}}, + resp: normalizeErrorEventResponse(null, fallbackMessage), }); return; } + const generation = ++addressFormGeneration; $.post( addressFormUrl, requestData, ).then((resp) => { + if (generation !== addressFormGeneration) { + return; + } + if (!resp || typeof resp.addresses_section !== 'string') { - prestashop.emit('handleError', {eventType: 'updateOpcAddressForm', resp}); + prestashop.emit('handleError', { + eventType: 'updateOpcAddressForm', + resp: normalizeErrorEventResponse(resp, fallbackMessage), + }); return; } @@ -431,10 +452,25 @@ function refreshOpcAddressFormForCountryChange(target, selectors) { syncBillingSectionConstraints(addressContainer, useSameAddress); prestashop.emit(OPC_EVENTS.updatedOpcAddressForm, {target: addressContainer, resp}); - prestashop.emit(OPC_EVENTS.opcDeliveryAddressUpdated, {target: addressContainer, resp}); - prestashop.emit(OPC_EVENTS.opcBillingAddressUpdated, {target: addressContainer, resp}); + const changedFieldName = String(target.attr('name') || ''); + + if (changedFieldName === 'id_country') { + emitAddressUpdate('delivery', {target: addressContainer, resp}); + return; + } + + if (changedFieldName === 'invoice_id_country') { + emitAddressUpdate('billing', {target: addressContainer, resp}); + } }).fail((resp) => { - prestashop.emit('handleError', {eventType: 'updateOpcAddressForm', resp}); + if (generation !== addressFormGeneration) { + return; + } + + prestashop.emit('handleError', { + eventType: 'updateOpcAddressForm', + resp: normalizeErrorEventResponse(resp && resp.responseJSON, fallbackMessage), + }); }); } diff --git a/views/js/opc-carrier-list.js b/views/js/opc-carrier-list.js index c42da77..a63e2b5 100644 --- a/views/js/opc-carrier-list.js +++ b/views/js/opc-carrier-list.js @@ -1,6 +1,6 @@ -import OPC_EVENTS from './events'; +import {CORE_EVENTS, OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; -import {getAjaxErrorResponse, getConfiguredOpcUrl, normalizeErrorResponse, updateCartSummary} from './runtime/opc-runtime'; +import {getAjaxErrorResponse, getConfiguredOpcMessage, getConfiguredOpcUrl, normalizeErrorResponse, updateCartSummary} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -19,6 +19,7 @@ const URL_KEY = 'carriers'; const CHECKOUT_FORM_SELECTOR = OPC_SELECTORS.opc.checkout; const FAILED_EVENT_NAME = OPC_EVENTS.opcCarriersFailed; let selectedDeliveryAddressId = null; +let fetchCarriersGeneration = 0; function getTemplateHtml(templateId) { const template = document.querySelector(`#${templateId}`); @@ -97,9 +98,29 @@ function buildCarriersUrl(baseUrl) { return url.toString(); } +function syncSelectedDeliveryAddressContext() { + const deliveryMethodsContainer = document.querySelector(CONTAINER_SELECTOR); + const selectedSavedDeliveryAddressId = getSelectedSavedDeliveryAddressId(); + + if (selectedSavedDeliveryAddressId) { + selectedDeliveryAddressId = selectedSavedDeliveryAddressId; + if (deliveryMethodsContainer) { + deliveryMethodsContainer.setAttribute('data-id-address', selectedSavedDeliveryAddressId); + } + + return; + } + + selectedDeliveryAddressId = null; + if (deliveryMethodsContainer) { + deliveryMethodsContainer.removeAttribute('data-id-address'); + } +} + function fetchCarriers() { const carriersUrl = buildCarriersUrl(getConfiguredOpcUrl(URL_KEY)); const $container = $(CONTAINER_SELECTOR); + const fallbackMessage = getConfiguredOpcMessage('loadCarriersFailed', 'Unable to load delivery methods.'); if (!carriersUrl || !$container.length) { return; @@ -107,11 +128,16 @@ function fetchCarriers() { $container.html(getTemplateHtml(OPC_SELECTORS.templates.carrierLoader.replace('#', ''))); prestashop.emit(OPC_EVENTS.opcCarriersLoading, {}); + const generation = ++fetchCarriersGeneration; $.get(carriersUrl) .done((response) => { + if (generation !== fetchCarriersGeneration) { + return; + } + if (!response || response.success === false) { - const resp = normalizeErrorResponse(response, 'Unable to load delivery methods.'); + const resp = normalizeErrorResponse(response, fallbackMessage); $container.html(getTemplateHtml(OPC_SELECTORS.templates.carrierError.replace('#', ''))); prestashop.emit(FAILED_EVENT_NAME, {resp}); prestashop.emit('handleError', {eventType: 'opcCarriers', resp}); @@ -143,7 +169,11 @@ function fetchCarriers() { } }) .fail((jqXHR) => { - const resp = getAjaxErrorResponse(jqXHR, 'Unable to load delivery methods.'); + if (generation !== fetchCarriersGeneration) { + return; + } + + const resp = getAjaxErrorResponse(jqXHR, fallbackMessage); $container.html(getTemplateHtml(OPC_SELECTORS.templates.carrierError.replace('#', ''))); prestashop.emit(FAILED_EVENT_NAME, {resp}); prestashop.emit('handleError', {eventType: 'opcCarriers', resp}); @@ -168,24 +198,7 @@ $(document).on('click', '[data-opc-action="retry-carriers"]', (event) => { prestashop.on(OPC_EVENTS.opcCarriersRetry, fetchCarriers); prestashop.on(OPC_EVENTS.opcDeliveryAddressUpdated, () => { - const deliveryMethodsContainer = document.querySelector(CONTAINER_SELECTOR); - const selectedSavedDeliveryAddressId = getSelectedSavedDeliveryAddressId(); - - if (selectedSavedDeliveryAddressId) { - selectedDeliveryAddressId = selectedSavedDeliveryAddressId; - if (deliveryMethodsContainer) { - deliveryMethodsContainer.setAttribute('data-id-address', selectedSavedDeliveryAddressId); - } - - fetchCarriers(); - return; - } - - selectedDeliveryAddressId = null; - if (deliveryMethodsContainer) { - deliveryMethodsContainer.removeAttribute('data-id-address'); - } - + syncSelectedDeliveryAddressContext(); fetchCarriers(); }); @@ -194,6 +207,16 @@ prestashop.on(OPC_EVENTS.opcDeliveryAddressSelected, ({idAddress}) => { fetchCarriers(); }); +prestashop.on(OPC_EVENTS.opcGuestInitSuccess, () => { + syncSelectedDeliveryAddressContext(); + fetchCarriers(); +}); + +prestashop.on(CORE_EVENTS.updatedCart, () => { + // Cart mutations can change shipping eligibility and carrier prices. + fetchCarriers(); +}); + const deliveryMethodsContainer = document.querySelector(CONTAINER_SELECTOR); const initiallySelectedSavedDeliveryAddressId = getSelectedSavedDeliveryAddressId(); if (deliveryMethodsContainer && initiallySelectedSavedDeliveryAddressId) { diff --git a/views/js/opc-carrier-select.js b/views/js/opc-carrier-select.js index c40de7d..a8f2975 100644 --- a/views/js/opc-carrier-select.js +++ b/views/js/opc-carrier-select.js @@ -1,6 +1,12 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; -import {getAjaxErrorResponse, getConfiguredOpcUrl, normalizeErrorResponse, updateCartSummary} from './runtime/opc-runtime'; +import { + getAjaxErrorEventResponse, + getConfiguredOpcMessage, + getConfiguredOpcUrl, + normalizeErrorEventResponse, + updateCartSummary, +} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -19,6 +25,7 @@ const URL_KEY = 'selectCarrier'; const CHECKOUT_FORM_SELECTOR = OPC_SELECTORS.opc.checkout; const DELIVERY_ADDRESS_SECTION_SELECTOR = OPC_SELECTORS.opc.deliverySection; const CONFIRMED_DELIVERY_OPTION_ATTRIBUTE = 'data-confirmed-delivery-option'; +let selectCarrierGeneration = 0; function getDeliveryAddressSection() { return document.querySelector(DELIVERY_ADDRESS_SECTION_SELECTOR); @@ -84,11 +91,14 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.deliveryO const $container = $(CONTAINER_SELECTOR); const selectCarrierUrl = getConfiguredOpcUrl(URL_KEY); const deliveryOption = String($radio.val() || ''); + const missingPayloadMessage = getConfiguredOpcMessage('missingCarrierSelectionPayload', 'Missing OPC carrier selection payload.'); + const selectionFailedMessage = getConfiguredOpcMessage('selectCarrierFailed', 'Unable to select the delivery method.'); if (!selectCarrierUrl || !deliveryOption) { + const resp = normalizeErrorEventResponse(null, missingPayloadMessage); prestashop.emit('handleError', { eventType: 'opcSelectCarrier', - resp: normalizeErrorResponse(null, 'Missing OPC carrier selection payload.'), + resp, }); return; } @@ -97,14 +107,19 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.deliveryO delivery_option: deliveryOption, ...($container.attr('data-id-address') ? {} : collectAddressFields()), }; + const generation = ++selectCarrierGeneration; $.post(selectCarrierUrl, payload) .done((response) => { + if (generation !== selectCarrierGeneration) { + return; + } + if (!response || response.success === false) { restoreConfirmedDeliveryOption($container); prestashop.emit('handleError', { eventType: 'opcSelectCarrier', - resp: normalizeErrorResponse(response, 'Unable to select the delivery method.'), + resp: normalizeErrorEventResponse(response, selectionFailedMessage), }); return; } @@ -116,10 +131,14 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.deliveryO prestashop.emit(OPC_EVENTS.opcCarrierSelected, response); }) .fail((jqXHR) => { + if (generation !== selectCarrierGeneration) { + return; + } + restoreConfirmedDeliveryOption($container); prestashop.emit('handleError', { eventType: 'opcSelectCarrier', - resp: getAjaxErrorResponse(jqXHR, 'Unable to select the delivery method.'), + resp: getAjaxErrorEventResponse(jqXHR, selectionFailedMessage), }); }); }); diff --git a/views/js/opc-gift-wrapping.js b/views/js/opc-gift-wrapping.js new file mode 100644 index 0000000..5d6eadc --- /dev/null +++ b/views/js/opc-gift-wrapping.js @@ -0,0 +1,59 @@ +import {OPC_EVENTS} from './events'; +import {getAjaxErrorEventResponse, getConfiguredOpcUrl, normalizeErrorEventResponse, updateCartSummary} from './runtime/opc-runtime'; + +(function psOpcGiftWrappingRuntime() { +const $ = window.$ || window.jQuery; +const prestashop = window.prestashop || {}; + +if (!$) { + return; +} + +const URL_KEY = 'giftWrapping'; +const GIFT_INPUT_SELECTOR = '#input_gift'; +const GIFT_MESSAGE_SELECTOR = '#gift_message'; +const FALLBACK_MESSAGE = 'Unable to refresh the cart total.'; + +function syncGiftWrappingSummary() { + const giftWrappingUrl = getConfiguredOpcUrl(URL_KEY); + const giftInput = document.querySelector(GIFT_INPUT_SELECTOR); + const giftMessage = document.querySelector(GIFT_MESSAGE_SELECTOR); + + if (!(giftInput instanceof HTMLInputElement) || !giftWrappingUrl) { + return; + } + + const previousChecked = !giftInput.checked; + + $.post(giftWrappingUrl, { + gift: giftInput.checked ? 1 : 0, + gift_message: giftMessage instanceof HTMLTextAreaElement ? giftMessage.value : '', + }) + .done((response) => { + if (!response || response.success === false) { + giftInput.checked = previousChecked; + prestashop.emit('handleError', { + eventType: OPC_EVENTS.opcGiftWrapping, + resp: normalizeErrorEventResponse(response, FALLBACK_MESSAGE), + }); + return; + } + + if (response.preview) { + updateCartSummary(response.preview, response.totals); + } + + prestashop.emit(OPC_EVENTS.opcCarriersRetry, { eventType: OPC_EVENTS.opcGiftWrapping }); + prestashop.emit(OPC_EVENTS.opcPaymentMethodsRetry, { eventType: OPC_EVENTS.opcGiftWrapping }); + }) + .fail((jqXHR) => { + giftInput.checked = previousChecked; + prestashop.emit('handleError', { + eventType: OPC_EVENTS.opcGiftWrapping, + resp: getAjaxErrorEventResponse(jqXHR, FALLBACK_MESSAGE), + }); + }); +} + +$(document).on('change', GIFT_INPUT_SELECTOR, syncGiftWrappingSummary); +}()); diff --git a/views/js/opc-guest-init.js b/views/js/opc-guest-init.js index 0953775..174ff33 100644 --- a/views/js/opc-guest-init.js +++ b/views/js/opc-guest-init.js @@ -1,6 +1,8 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; +import {getConfiguredOpcMessage} from './runtime/opc-runtime'; import {getConfiguredOpcUrl} from './runtime/opc-runtime'; +import {normalizeErrorEventResponse} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -224,20 +226,24 @@ function collectPayload($container) { return payload; } -function collectRequiredCheckboxState($container) { - const requiredCheckboxes = {}; +function collectCheckboxState($container, selector = CHECKBOX_FIELD_SELECTOR) { + const checkboxes = {}; - $container.find(`${CHECKBOX_FIELD_SELECTOR}[required]`).each((_, checkbox) => { + $container.find(selector).each((_, checkbox) => { const fieldName = checkbox.name; - if (!fieldName || isFieldInsideAddressModal(checkbox)) { + if (!fieldName || checkbox.disabled || isFieldInsideAddressModal(checkbox)) { return; } - requiredCheckboxes[fieldName] = checkbox.checked ? '1' : '0'; + checkboxes[fieldName] = checkbox.checked ? '1' : '0'; }); - return requiredCheckboxes; + return checkboxes; +} + +function collectRequiredCheckboxState($container) { + return collectCheckboxState($container, `${CHECKBOX_FIELD_SELECTOR}[required]`); } function getPayloadFingerprint(payload) { @@ -247,7 +253,8 @@ function getPayloadFingerprint(payload) { } function hasMissingRequiredConsent(requiredCheckboxState) { - return Object.values(requiredCheckboxState).includes('0'); + return Object.keys(requiredCheckboxState).length === 0 + || Object.values(requiredCheckboxState).includes('0'); } function setInitialGuestEmailFingerprint() { @@ -334,14 +341,6 @@ function tryGuestInit() { } const guestInitUrl = getConfiguredOpcUrl(MODULE_GUEST_INIT_URL_KEY); - if (guestInitUrl === '') { - prestashop.emit('handleError', { - eventType: 'opcGuestInit', - resp: {errors: {'': ['Missing OPC guest init URL.']}}, - }); - - return; - } inFlightRequest = $.post( guestInitUrl, @@ -379,7 +378,9 @@ function tryGuestInit() { // Token errors need fresh context, avoid retry loops while payload stays unchanged. lastSubmittedFingerprint = payloadFingerprint; } else if (resp && resp.success === false) { - prestashop.emit('handleError', {eventType: 'opcGuestInit', resp}); + prestashop.emit(OPC_EVENTS.opcGuestInitFailed, { + resp: normalizeErrorEventResponse(resp), + }); } }) .fail((resp) => { @@ -387,7 +388,9 @@ function tryGuestInit() { return; } - prestashop.emit('handleError', {eventType: 'opcGuestInit', resp}); + prestashop.emit(OPC_EVENTS.opcGuestInitFailed, { + resp: normalizeErrorEventResponse(resp), + }); }) .always(() => { pendingFingerprint = ''; diff --git a/views/js/opc-payment-list.js b/views/js/opc-payment-list.js index d438b13..bed9ca4 100644 --- a/views/js/opc-payment-list.js +++ b/views/js/opc-payment-list.js @@ -1,6 +1,6 @@ -import OPC_EVENTS from './events'; +import {CORE_EVENTS, OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; -import {getAjaxErrorResponse, getConfiguredOpcUrl, normalizeErrorResponse} from './runtime/opc-runtime'; +import {getAjaxErrorResponse, getConfiguredOpcMessage, getConfiguredOpcUrl, normalizeErrorResponse} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -33,6 +33,18 @@ function getCheckoutForm() { return document.querySelector(CHECKOUT_FORM_SELECTOR); } +function hasSelectedCarrier() { + const deliveryMethods = document.querySelector(OPC_SELECTORS.opc.deliveryMethods); + + if (!(deliveryMethods instanceof HTMLElement)) { + return false; + } + + return Boolean( + deliveryMethods.querySelector(`${OPC_SELECTORS.inputs.deliveryOption}:checked`) + ); +} + function getSelectedSavedAddressId(listSelector, radioName) { const selectedRadio = document.querySelector( `${listSelector} ${OPC_SELECTORS.opc.addressRadio}[name="${radioName}"]:checked` @@ -97,6 +109,7 @@ function buildPaymentMethodsUrl(baseUrl) { function fetchPaymentMethods() { const $container = getContainer(); const paymentMethodsUrl = buildPaymentMethodsUrl(getConfiguredOpcUrl(URL_KEY)); + const fallbackMessage = getConfiguredOpcMessage('loadPaymentMethodsFailed', 'Unable to load payment methods.'); if (!$container.length || !paymentMethodsUrl) { return; @@ -112,7 +125,7 @@ function fetchPaymentMethods() { } if (!response || response.success === false) { - const error = normalizeErrorResponse(response, 'Unable to load payment methods.'); + const error = normalizeErrorResponse(response, fallbackMessage); $container.html(getTemplateHtml(OPC_SELECTORS.templates.paymentError.replace('#', ''))); prestashop.emit(OPC_EVENTS.opcPaymentMethodsFailed, {error}); prestashop.emit('handleError', {eventType: 'opcPaymentMethods', resp: error}); @@ -128,7 +141,7 @@ function fetchPaymentMethods() { return; } - const error = getAjaxErrorResponse(jqXHR, 'Unable to load payment methods.'); + const error = getAjaxErrorResponse(jqXHR, fallbackMessage); $container.html(getTemplateHtml(OPC_SELECTORS.templates.paymentError.replace('#', ''))); prestashop.emit(OPC_EVENTS.opcPaymentMethodsFailed, {error}); prestashop.emit('handleError', {eventType: 'opcPaymentMethods', resp: error}); @@ -141,7 +154,14 @@ $(document).on('click', '[data-opc-action="retry-payment"]', (event) => { fetchPaymentMethods(); }); +prestashop.on(CORE_EVENTS.updatedCart, fetchPaymentMethods); prestashop.on(OPC_EVENTS.opcCarrierSelected, fetchPaymentMethods); +prestashop.on(OPC_EVENTS.opcCarriersUpdated, () => { + if (!hasSelectedCarrier()) { + fetchPaymentMethods(); + } +}); +prestashop.on(OPC_EVENTS.opcBillingAddressSelected, fetchPaymentMethods); prestashop.on(OPC_EVENTS.opcBillingAddressUpdated, fetchPaymentMethods); prestashop.on(OPC_EVENTS.opcGuestInitSuccess, fetchPaymentMethods); prestashop.on(OPC_EVENTS.opcPaymentMethodsRetry, fetchPaymentMethods); diff --git a/views/js/opc-payment-select.js b/views/js/opc-payment-select.js index 200f939..372a4ca 100644 --- a/views/js/opc-payment-select.js +++ b/views/js/opc-payment-select.js @@ -1,6 +1,11 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; -import {getAjaxErrorResponse, getConfiguredOpcUrl, normalizeErrorResponse} from './runtime/opc-runtime'; +import { + getAjaxErrorEventResponse, + getConfiguredOpcMessage, + getConfiguredOpcUrl, + normalizeErrorEventResponse, +} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -20,6 +25,7 @@ const EVENT_NAME = OPC_EVENTS.opcPaymentMethodSelected; const CONFIRMED_OPTION_ATTRIBUTE = 'data-confirmed-payment-option-id'; const CONFIRMED_MODULE_ATTRIBUTE = 'data-confirmed-payment-module'; const CONFIRMED_SELECTION_KEY_ATTRIBUTE = 'data-confirmed-payment-selection-key'; +let selectPaymentGeneration = 0; function togglePaymentPanels($container, paymentOptionId) { $container.find('.js-additional-information, .js-payment-option-form').hide(); @@ -83,17 +89,21 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.paymentOp const paymentSelectionKey = String($radio.data('selectionKey') || $radio.data('selection-key') || ''); const selectPaymentUrl = getConfiguredOpcUrl(URL_KEY); const $container = $(CONTAINER_SELECTOR); + const missingPayloadMessage = getConfiguredOpcMessage('missingPaymentSelectionPayload', 'Missing OPC payment selection payload.'); + const selectionFailedMessage = getConfiguredOpcMessage('selectPaymentFailed', 'Unable to select the payment method.'); if (!selectPaymentUrl || !paymentOptionId || !paymentModuleName || !paymentSelectionKey) { + const resp = normalizeErrorEventResponse(null, missingPayloadMessage); prestashop.emit('handleError', { eventType: 'opcSelectPayment', - resp: normalizeErrorResponse(null, 'Missing OPC payment selection payload.'), + resp, }); return; } togglePaymentPanels($container, paymentOptionId); + const generation = ++selectPaymentGeneration; $.post(selectPaymentUrl, { payment_option: paymentOptionId, @@ -101,11 +111,15 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.paymentOp payment_selection_key: paymentSelectionKey, }) .done((response) => { + if (generation !== selectPaymentGeneration) { + return; + } + if (!response || response.success === false) { restoreConfirmedPaymentSelection($container); prestashop.emit('handleError', { eventType: 'opcSelectPayment', - resp: normalizeErrorResponse(response, 'Unable to select the payment method.'), + resp: normalizeErrorEventResponse(response, selectionFailedMessage), }); return; @@ -121,10 +135,14 @@ $(document).on('change', `${CONTAINER_SELECTOR} ${OPC_SELECTORS.inputs.paymentOp }); }) .fail((jqXHR) => { + if (generation !== selectPaymentGeneration) { + return; + } + restoreConfirmedPaymentSelection($container); prestashop.emit('handleError', { eventType: 'opcSelectPayment', - resp: getAjaxErrorResponse(jqXHR, 'Unable to select the payment method.'), + resp: getAjaxErrorEventResponse(jqXHR, selectionFailedMessage), }); }); }); diff --git a/views/js/opc-submit.js b/views/js/opc-submit.js index 7919cff..cf1d402 100644 --- a/views/js/opc-submit.js +++ b/views/js/opc-submit.js @@ -1,6 +1,6 @@ -import OPC_EVENTS from './events'; +import {OPC_EVENTS} from './events'; import OPC_SELECTORS from './selectors'; -import {getConfiguredOpcUrl, normalizeErrorResponse, updatePayAmount} from './runtime/opc-runtime'; +import {getConfiguredOpcUrl, normalizeErrorEventResponse, getConfiguredOpcMessage, updatePayAmount} from './runtime/opc-runtime'; /** * Copyright since 2007 PrestaShop SA and Contributors @@ -309,15 +309,15 @@ function ajaxCheckCartStillOrderable() { } function emitSubmitFailure(response) { + const normalizedResponse = normalizeErrorEventResponse(response); prestashop.emit('handleError', { eventType: 'opcSubmit', - resp: response, + resp: normalizedResponse, }); - prestashop.emit(OPC_EVENTS.opcSubmitFailed, {resp: response}); } function emitSubmitRuntimeError(message) { - emitSubmitFailure(normalizeErrorResponse(null, message)); + emitSubmitFailure(normalizeErrorEventResponse(null, message)); } function submitPaymentModuleForm(paymentRadio) { @@ -333,7 +333,9 @@ function submitPaymentModuleForm(paymentRadio) { return; } - emitSubmitRuntimeError('Missing rendered payment form for the selected option.'); + emitSubmitRuntimeError( + getConfiguredOpcMessage('missingPaymentForm', 'Unable to initialize the selected payment method.') + ); } function buildSubmitPayload(form, paymentRadio) { @@ -400,7 +402,7 @@ function getOpcSubmitUrl() { return submitUrl; } - emitSubmitRuntimeError('Missing OPC submit URL.'); + emitSubmitRuntimeError(getConfiguredOpcMessage('missingSubmitUrl', 'Unable to submit checkout.')); return ''; } @@ -425,6 +427,13 @@ function handleOpcSubmitFailure(response) { return true; } + const normalizedResponse = normalizeErrorEventResponse(response); + if (normalizedResponse.errors.length === 0) { + emitSubmitRuntimeError(getConfiguredOpcMessage('submitFailed', 'Unable to submit checkout.')); + + return true; + } + emitSubmitFailure(response); return true; @@ -486,11 +495,12 @@ async function submitOpcPay(form) { await continueSuccessfulSubmit(response, paymentSelection.paymentRadio); } catch (error) { - prestashop.emit('handleError', { - eventType: 'opcSubmit', - resp: {}, - }); - prestashop.emit(OPC_EVENTS.opcSubmitFailed, {error}); + emitSubmitFailure( + normalizeErrorEventResponse( + null, + getConfiguredOpcMessage('submitFailed', 'Unable to submit checkout.') + ) + ); } finally { isFinalSubmitInFlight = false; validateForm(); diff --git a/views/js/runtime/opc-runtime.js b/views/js/runtime/opc-runtime.js index 915744c..98c2541 100644 --- a/views/js/runtime/opc-runtime.js +++ b/views/js/runtime/opc-runtime.js @@ -1,4 +1,4 @@ -import OPC_EVENTS from '../events'; +import {OPC_EVENTS} from '../events'; import OPC_SELECTORS from '../selectors'; export function isPlainObject(value) { @@ -6,21 +6,79 @@ export function isPlainObject(value) { } export function normalizeErrorResponse(response, fallbackMessage) { - if (isPlainObject(response)) { - return response; - } - - return { - errors: { - '': [fallbackMessage], - }, - }; + return normalizeErrorEventResponse(response, fallbackMessage); } export function getAjaxErrorResponse(jqXHR, fallbackMessage) { return normalizeErrorResponse(jqXHR && jqXHR.responseJSON, fallbackMessage); } +function normalizeErrorMessage(message) { + if (typeof message !== 'string') { + return ''; + } + + return message.trim(); +} + +function extractMessages(source) { + if (Array.isArray(source)) { + return source; + } + + if (isPlainObject(source)) { + return Object.values(source).flatMap((value) => (Array.isArray(value) ? value : [value])); + } + + return [source]; +} + +export function getErrorMessages(response, fallbackMessage = '') { + const fallback = normalizeErrorMessage(fallbackMessage); + + const messages = isPlainObject(response) + ? [...extractMessages(response.errors), response.message] + : [response]; + + const normalizedMessages = messages + .map(normalizeErrorMessage) + .filter(Boolean); + + if (normalizedMessages.length > 0) { + return [...new Set(normalizedMessages)]; + } + + return fallback ? [fallback] : []; +} + +export function normalizeErrorEventResponse(response, fallbackMessage = '') { + const normalizedResponse = isPlainObject(response) + ? {...response} + : {}; + + normalizedResponse.errors = getErrorMessages(response, fallbackMessage); + + return normalizedResponse; +} + +export function getAjaxErrorEventResponse(jqXHR, fallbackMessage = '') { + return normalizeErrorEventResponse(jqXHR && jqXHR.responseJSON, fallbackMessage); +} + +export function getConfiguredOpcMessage(messageKey, fallbackMessage = '') { + const runtimeConfiguration = getOpcRuntimeConfiguration(); + + if ( + runtimeConfiguration + && runtimeConfiguration.messages + && typeof runtimeConfiguration.messages[messageKey] === 'string' + ) { + return String(runtimeConfiguration.messages[messageKey]); + } + + return fallbackMessage; +} + export function getOpcRuntimeConfiguration() { if (!window || typeof window.ps_onepagecheckout !== 'object' || !window.ps_onepagecheckout) { return null; diff --git a/views/public/opc-address-modal.bundle.js b/views/public/opc-address-modal.bundle.js index da88340..9b873fb 100644 --- a/views/public/opc-address-modal.bundle.js +++ b/views/public/opc-address-modal.bundle.js @@ -1 +1 @@ -(()=>{"use strict";const e={opcCarrierSelected:"opcCarrierSelected",opcCarriersUpdated:"opcCarriersUpdated",opcCarriersFailed:"opcCarriersFailed",opcCarriersLoading:"opcCarriersLoading",opcPaymentMethodsLoading:"opcPaymentMethodsLoading",opcPaymentMethodsUpdated:"opcPaymentMethodsUpdated",opcPaymentMethodsFailed:"opcPaymentMethodsFailed",opcPaymentMethodSelected:"opcPaymentMethodSelected",opcGuestInitSuccess:"opcGuestInitSuccess",opcFinalSubmitStarted:"opcFinalSubmitStarted",opcFormValidated:"opcFormValidated",opcBillingSectionToggled:"opcBillingSectionToggled",opcSubmitFailed:"opcSubmitFailed",opcDeliveryAddressUpdated:"opcDeliveryAddressUpdated",opcBillingAddressUpdated:"opcBillingAddressUpdated",opcDeliveryAddressSelected:"opcDeliveryAddressSelected",opcBillingAddressSelected:"opcBillingAddressSelected",opcCartSummaryBeforeUpdate:"opcCartSummaryBeforeUpdate",opcCartSummaryUpdated:"opcCartSummaryUpdated",updatedOpcAddressForm:"updatedOpcAddressForm",opcCarriersRetry:"opcCarriersRetry",opcPaymentMethodsRetry:"opcPaymentMethodsRetry"};const t={opc:{checkout:".one-page-checkout",form:"#opc-form",payButton:"#opc-pay-button",payAmount:"#opc-pay-amount",addressesSection:".js-opc-addresses-section",deliveryMethods:"#opc-delivery-methods",paymentMethods:"#opc-payment-methods",deliverySection:"#opc-delivery-address",deliveryFields:"#opc-delivery-address-fields",billingSection:"#opc-billing-section",billingFields:"#opc-billing-address-fields",deliveryList:"#opc-delivery-address-content-list",billingList:"#opc-billing-address-content-list",useSameAddress:'[name="use_same_address"]',checkoutFooter:".one-page-checkout__footer",contactSection:".js-opc-contact-section",addressRadio:".js-opc-address-radio",addressItem:".opc-address-item",addressLabel:".form-check-label"},templates:{carrierLoader:"#opc-template-loader",carrierError:"#opc-template-carriers-error",paymentLoader:"#opc-template-payment-loader",paymentError:"#opc-template-payment-error"},inputs:{deliveryOption:'input[name="delivery_option"]',paymentOption:'input[name="payment-option"]',email:'input[name="email"]',conditions:'.one-page-checkout input[name^="conditions_to_approve["][required]'},modals:{address:"#opc-address-modal, #modal-delivery, #modal-invoice"}};function r(e){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},r(e)}function n(e){var t=window&&"object"===r(window.ps_onepagecheckout)&&window.ps_onepagecheckout?window.ps_onepagecheckout:null;return t&&t.urls&&t.urls[e]?String(t.urls[e]):""}function o(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=r){var n,o,i,d,a=[],s=!0,c=!1;try{if(i=(r=r.call(e)).next,0===t){if(Object(r)!==r)return;s=!1}else for(;!(s=(n=i.call(r)).done)&&(a.push(n.value),a.length!==t);s=!0);}catch(e){c=!0,o=e}finally{try{if(!s&&null!=r.return&&(d=r.return(),Object(d)!==d))return}finally{if(c)throw o}}return a}}(e,t)||function(e,t){if(e){if("string"==typeof e)return i(e,t);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?i(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r0}(e))||e.checkValidity())}function K(e){var t=e.find(s);if(t.length)if(e.get(0)instanceof HTMLElement&&e.hasClass("show")){var r=e.find("input, select, textarea").toArray().every(function(e){return Q(e)});t.prop("disabled",!r)}else t.prop("disabled",!0)}function W(e,t,n){var o=function(e){return{$wrapper:e.find(".state-field-wrapper, #state-field-wrapper").first(),$select:e.find('[name="id_state"], [name$="id_state"]').first(),$row:e.find(".address-country-row, #address-country-row").first()}}(e),i=o.$wrapper,d=o.$select,a=o.$row;if(i.length&&d.length){var s=t&&Array.isArray(t.states)?t.states:[];if(!(Boolean(t&&t.hasStates)||s.length>0))return i.hide(),d.prop("required",!1).val(""),void(a.length&&a.removeClass("form-fields-row--3").addClass("form-fields-row--2"));var c=String(d.attr("data-select-placeholder")||"");d.empty(),d.append(r("