From 076438265126a6e09217384b258b2a4100a2cdff Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Thu, 12 Mar 2026 16:52:07 +0200 Subject: [PATCH 01/30] improve: adjustments with back end and customer id parsing --- Makefile | 12 + ...dminSaferPayOfficialSettingsController.php | 239 +++++++++++++---- .../GetTerminals/GetTerminalsRequest.php | 4 + src/Service/SettingsTranslationService.php | 240 ++++++++++++++++++ views/js/admin/settings-app/src/App.tsx | 35 ++- views/js/admin/settings-app/src/api/client.ts | 34 ++- .../components/settings/api-credentials.tsx | 176 ++++++------- .../settings/email-notifications.tsx | 26 +- .../components/settings/general-settings.tsx | 40 +-- .../components/settings/payment-methods.tsx | 78 +++--- .../settings/payment-processing.tsx | 100 ++++---- .../components/settings/saferpay-settings.tsx | 15 +- .../components/settings/toast-container.tsx | 10 +- .../src/context/settings-context.tsx | 194 ++++++++------ .../settings-app/src/hooks/use-mobile.tsx | 19 -- .../admin/settings-app/src/hooks/use-toast.ts | 2 +- views/js/admin/settings-app/src/main.tsx | 34 ++- .../js/admin/settings-app/src/types/index.ts | 5 +- .../settings-app/src/utils/translations.ts | 13 + views/templates/admin/settings_react.tpl | 4 +- 20 files changed, 890 insertions(+), 390 deletions(-) create mode 100644 src/Service/SettingsTranslationService.php delete mode 100644 views/js/admin/settings-app/src/hooks/use-mobile.tsx create mode 100644 views/js/admin/settings-app/src/utils/translations.ts diff --git a/Makefile b/Makefile index 6d7ccb15..c70260db 100755 --- a/Makefile +++ b/Makefile @@ -147,6 +147,18 @@ test-e2e-headless-1786: build-react: cd views/js/admin/settings-app && pnpm install && pnpm run build +# target: dev-react - Start React dev server with HMR +dev-react: + cd views/js/admin/settings-app && pnpm dev + +# target: watch-react - Build React app and watch for changes +watch-react: + cd views/js/admin/settings-app && pnpm run build --watch + +# target: lint-react - Run TypeScript type check +lint-react: + cd views/js/admin/settings-app && pnpm run tsc --noEmit + prepare-zip: rm -rf vendor && \ composer install --no-dev --optimize-autoloader && \ diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index 2e952cc9..2b0ad907 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -50,6 +50,16 @@ class AdminSaferPayOfficialSettingsController extends ModuleAdminController const FILE_NAME = 'AdminSaferPayOfficialSettingsController'; const PASSWORD_PLACEHOLDER = '********'; + const ALLOWED_AJAX_ACTIONS = [ + 'saveCredentials', + 'savePaymentProcessing', + 'saveEmailSettings', + 'saveGeneralSettings', + 'savePaymentMethods', + 'getTerminals', + 'refreshData', + ]; + /** @var \SaferPayOfficial */ public $module; @@ -90,13 +100,19 @@ public function postProcess() return parent::postProcess(); } + if (!$this->validateAjaxToken()) { + $this->ajaxResponse(false, $this->module->l('Invalid security token', self::FILE_NAME)); + return; + } + $action = Tools::getValue('action'); - if ($action) { - $methodName = 'ajaxProcess' . ucfirst($action); - if (method_exists($this, $methodName)) { - $this->{$methodName}(); - } + if (!$action || !in_array($action, self::ALLOWED_AJAX_ACTIONS)) { + $this->ajaxResponse(false, $this->module->l('Invalid action', self::FILE_NAME)); + return; } + + $methodName = 'ajaxProcess' . ucfirst($action); + $this->{$methodName}(); } /** @@ -104,7 +120,17 @@ public function postProcess() */ private function isAjax() { - return Tools::getValue('ajax') == 1; + return (int) Tools::getValue('ajax') === 1; + } + + /** + * Validate AJAX requests come from authenticated admin + */ + private function validateAjaxToken() + { + // In PS9, the routing layer already validates the admin token in the URL + // before the controller is reached. We just verify the employee is logged in. + return $this->context->employee && $this->context->employee->id; } /** @@ -114,23 +140,52 @@ public function ajaxProcessSaveCredentials() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - // Test mode - $configuration->set(SaferPayConfig::TEST_MODE, !empty($data['testMode']) ? 1 : 0); + // Resolve active credentials for validation before saving + $isTestMode = !empty($data['testMode']); + $activeUsername = $isTestMode ? $this->getStringValue($data, 'testUsername') : $this->getStringValue($data, 'liveUsername'); + $activePassword = $isTestMode ? $this->getStringValue($data, 'testPassword') : $this->getStringValue($data, 'livePassword'); + $activeCustomerId = $this->parseCustomerIdFromUsername($activeUsername); + + if ($activePassword === self::PASSWORD_PLACEHOLDER) { + $passwordSuffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + $activePassword = (string) $configuration->get(SaferPayConfig::PASSWORD . $passwordSuffix); + } + + // Validate credentials against Saferpay API before saving + if (!empty($activeUsername) && !empty($activePassword)) { + if (empty($activeCustomerId)) { + $this->ajaxResponse(false, $this->module->l('Invalid API username. Please check your credentials and try again.', self::FILE_NAME)); + return; + } + + try { + /** @var SaferPayGetTerminals $getTerminals */ + $getTerminals = $this->module->getService(SaferPayGetTerminals::class); + $getTerminals->fetchTerminalsWithCredentials($activeUsername, $activePassword, $activeCustomerId, $isTestMode); + } catch (\Exception $e) { + $this->ajaxResponse(false, $this->parseApiErrorMessage($e->getMessage())); + return; + } + } + + // Credentials validated — now save + $configuration->set(SaferPayConfig::TEST_MODE, $isTestMode ? 1 : 0); // Test credentials - $configuration->set(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testUsername')); + $testUsername = $this->getStringValue($data, 'testUsername'); + $configuration->set(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX, $testUsername); $testPassword = $this->getStringValue($data, 'testPassword'); if ($testPassword && $testPassword !== self::PASSWORD_PLACEHOLDER) { $configuration->set(SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX, $testPassword); } - $configuration->set(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testCustomerId')); + $configuration->set(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX, $this->parseCustomerIdFromUsername($testUsername)); $configuration->set(SaferPayConfig::TERMINAL_ID . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testTerminalId')); $configuration->set(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testFieldAccessToken')); @@ -138,12 +193,13 @@ public function ajaxProcessSaveCredentials() $configuration->set(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX, !empty($data['testBusinessLicense']) ? 1 : 0); // Live credentials - $configuration->set(SaferPayConfig::USERNAME, $this->getStringValue($data, 'liveUsername')); + $liveUsername = $this->getStringValue($data, 'liveUsername'); + $configuration->set(SaferPayConfig::USERNAME, $liveUsername); $livePassword = $this->getStringValue($data, 'livePassword'); if ($livePassword && $livePassword !== self::PASSWORD_PLACEHOLDER) { $configuration->set(SaferPayConfig::PASSWORD, $livePassword); } - $configuration->set(SaferPayConfig::CUSTOMER_ID, $this->getStringValue($data, 'liveCustomerId')); + $configuration->set(SaferPayConfig::CUSTOMER_ID, $this->parseCustomerIdFromUsername($liveUsername)); $configuration->set(SaferPayConfig::TERMINAL_ID, $this->getStringValue($data, 'liveTerminalId')); $configuration->set(SaferPayConfig::MERCHANT_EMAILS, $this->getStringValue($data, 'liveMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN, $this->getStringValue($data, 'liveFieldAccessToken')); @@ -154,14 +210,22 @@ public function ajaxProcessSaveCredentials() $suffix = SaferPayConfig::getConfigSuffix(); $haveFieldToken = $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . $suffix); $haveBusinessLicense = $configuration->get(SaferPayConfig::BUSINESS_LICENSE . $suffix); + $businessLicenseDisabled = false; if (!$haveFieldToken && $haveBusinessLicense) { $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, 0); - $this->ajaxResponse(true, 'Saved, but Field Access Token is required to use business license. Business license was disabled.'); + $businessLicenseDisabled = true; + } + + if ($businessLicenseDisabled) { + $this->ajaxResponse( + true, + $this->module->l('Credentials saved. Field Access Token is required for business license — it has been disabled.', self::FILE_NAME) + ); return; } - $this->ajaxResponse(true, 'API Credentials saved successfully'); + $this->ajaxResponse(true, $this->module->l('API Credentials saved successfully', self::FILE_NAME)); } /** @@ -171,20 +235,20 @@ public function ajaxProcessSavePaymentProcessing() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR, (int) $this->getIntValue($data, 'paymentBehavior')); - $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D, (int) $this->getIntValue($data, 'paymentBehaviorWithout3D')); - $configuration->set(SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT, (int) $this->getIntValue($data, 'restrictRefund')); - $configuration->set(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, (int) $this->getIntValue($data, 'orderCreationAfterAuth')); + $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR, $this->getIntValue($data, 'paymentBehavior')); + $configuration->set(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D, $this->getIntValue($data, 'paymentBehaviorWithout3D')); + $configuration->set(SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT, $this->getIntValue($data, 'restrictRefund')); + $configuration->set(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, $this->getIntValue($data, 'orderCreationAfterAuth')); $configuration->set(SaferPayConfig::SAFERPAY_GROUP_CARDS, !empty($data['groupCards']) ? 1 : 0); $configuration->set(SaferPayConfig::SAFERPAY_GROUP_CARDS_LOGO, !empty($data['groupCardsLogo']) ? 1 : 0); - $configuration->set(SaferPayConfig::CREDIT_CARD_SAVE, (int) $this->getIntValue($data, 'creditCardSave')); + $configuration->set(SaferPayConfig::CREDIT_CARD_SAVE, $this->getIntValue($data, 'creditCardSave')); // If credit card save disabled, clean up saved cards if (empty($data['creditCardSave']) || (int) $data['creditCardSave'] === 0) { @@ -193,7 +257,7 @@ public function ajaxProcessSavePaymentProcessing() $cardRepo->deleteAllSavedCreditCards(); } - $this->ajaxResponse(true, 'Payment Processing saved successfully'); + $this->ajaxResponse(true, $this->module->l('Payment Processing saved successfully', self::FILE_NAME)); } /** @@ -203,7 +267,7 @@ public function ajaxProcessSaveEmailSettings() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } @@ -214,7 +278,7 @@ public function ajaxProcessSaveEmailSettings() $configuration->set(SaferPayConfig::SAFERPAY_SEND_NEW_ORDER_MAIL, !empty($data['sendNewOrderMail']) ? 1 : 0); $configuration->set(SaferPayConfig::SAFERPAY_SEND_ORDER_CONF_MAIL, !empty($data['sendOrderConfMail']) ? 1 : 0); - $this->ajaxResponse(true, 'Email settings saved successfully'); + $this->ajaxResponse(true, $this->module->l('Email settings saved successfully', self::FILE_NAME)); } /** @@ -224,19 +288,19 @@ public function ajaxProcessSaveGeneralSettings() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); - $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, (int) $this->getIntValue($data, 'orderStateAwaitingPayment')); + $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, $this->getIntValue($data, 'orderStateAwaitingPayment')); $configuration->set(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION, $this->getStringValue($data, 'paymentDescription')); $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $this->getStringValue($data, 'configurationName')); $configuration->set(SaferPayConfig::SAFERPAY_DEBUG_MODE, !empty($data['debugMode']) ? 1 : 0); - $this->ajaxResponse(true, 'General settings saved successfully'); + $this->ajaxResponse(true, $this->module->l('General settings saved successfully', self::FILE_NAME)); } /** @@ -246,7 +310,7 @@ public function ajaxProcessSavePaymentMethods() { $data = $this->getJsonInput(); if (!$data || !isset($data['paymentMethods'])) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } @@ -274,37 +338,41 @@ public function ajaxProcessSavePaymentMethods() $success = true; foreach ($data['paymentMethods'] as $method) { + if (!isset($method['name']) || !is_string($method['name'])) { + continue; + } + $paymentName = $method['name']; - $success &= $paymentCreation->updatePayment($paymentName, !empty($method['enabled'])); - $success &= $logoCreation->updateLogo($paymentName, !empty($method['showLogos'])); - $success &= $fieldCreation->updateField($paymentName, !empty($method['showCustomForm'])); + $success = $paymentCreation->updatePayment($paymentName, !empty($method['enabled'])) && $success; + $success = $logoCreation->updateLogo($paymentName, !empty($method['showLogos'])) && $success; + $success = $fieldCreation->updateField($paymentName, !empty($method['showCustomForm'])) && $success; try { $countries = isset($method['countries']) ? $method['countries'] : []; $currencies = isset($method['currencies']) ? $method['currencies'] : []; - $success &= $restrictionCreator->updateRestriction( + $success = $restrictionCreator->updateRestriction( $paymentName, SaferPayRestrictionCreator::RESTRICTION_COUNTRY, $countries - ); - $success &= $restrictionCreator->updateRestriction( + ) && $success; + $success = $restrictionCreator->updateRestriction( $paymentName, SaferPayRestrictionCreator::RESTRICTION_CURRENCY, $currencies - ); + ) && $success; } catch (RestrictionException $e) { - $this->ajaxResponse(false, 'Wrong restriction type'); + $this->ajaxResponse(false, $this->module->l('Wrong restriction type', self::FILE_NAME)); return; } } if (!$success) { - $this->ajaxResponse(false, 'Failed to update payment methods'); + $this->ajaxResponse(false, $this->module->l('Failed to update payment methods', self::FILE_NAME)); return; } - $this->ajaxResponse(true, 'Payment methods saved successfully'); + $this->ajaxResponse(true, $this->module->l('Payment methods saved successfully', self::FILE_NAME)); } /** @@ -314,13 +382,13 @@ public function ajaxProcessGetTerminals() { $data = $this->getJsonInput(); if (!$data) { - $this->ajaxResponse(false, 'Invalid request data'); + $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); return; } $username = isset($data['username']) ? $data['username'] : ''; $password = isset($data['password']) ? $data['password'] : ''; - $customerId = isset($data['customerId']) ? $data['customerId'] : ''; + $customerId = $this->parseCustomerIdFromUsername($username); $isTestMode = isset($data['env']) && $data['env'] === 'test'; if ($password === self::PASSWORD_PLACEHOLDER) { @@ -331,7 +399,7 @@ public function ajaxProcessGetTerminals() } if (empty($username) || empty($password) || empty($customerId)) { - $this->ajaxResponse(false, 'Username, password and customer ID are required'); + $this->ajaxResponse(false, $this->module->l('Username and password are required', self::FILE_NAME)); return; } @@ -340,12 +408,12 @@ public function ajaxProcessGetTerminals() $getTerminals = $this->module->getService(SaferPayGetTerminals::class); $terminals = $getTerminals->fetchTerminalsWithCredentials($username, $password, $customerId, $isTestMode); - $this->ajaxDie(json_encode([ + $this->sendJsonResponse([ 'success' => true, 'terminals' => $terminals, - ])); + ]); } catch (\Exception $e) { - $this->ajaxResponse(false, 'Failed to fetch terminals: ' . $e->getMessage()); + $this->ajaxResponse(false, $this->module->l('Failed to fetch terminals. Please check your credentials.', self::FILE_NAME)); } } @@ -355,10 +423,10 @@ public function ajaxProcessGetTerminals() public function ajaxProcessRefreshData() { $settingsData = $this->collectSettingsData(); - $this->ajaxDie(json_encode([ + $this->sendJsonResponse([ 'success' => true, 'data' => $settingsData, - ])); + ]); } /** @@ -376,9 +444,8 @@ private function collectSettingsData() // Test credentials 'testUsername' => (string) $configuration->get(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX), 'testPassword' => $configuration->get(SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX) ? self::PASSWORD_PLACEHOLDER : '', - 'testCustomerId' => (string) $configuration->get(SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX), 'testTerminalId' => (string) $configuration->get(SaferPayConfig::TERMINAL_ID . SaferPayConfig::TEST_SUFFIX), - 'testMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX), + 'testMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'testFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX), 'testFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX), 'testBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX), @@ -386,9 +453,8 @@ private function collectSettingsData() // Live credentials 'liveUsername' => (string) $configuration->get(SaferPayConfig::USERNAME), 'livePassword' => $configuration->get(SaferPayConfig::PASSWORD) ? self::PASSWORD_PLACEHOLDER : '', - 'liveCustomerId' => (string) $configuration->get(SaferPayConfig::CUSTOMER_ID), 'liveTerminalId' => (string) $configuration->get(SaferPayConfig::TERMINAL_ID), - 'liveMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS), + 'liveMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'liveFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN), 'liveFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY), 'liveBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), @@ -422,11 +488,25 @@ private function collectSettingsData() // Endpoints 'ajaxUrl' => $this->context->link->getAdminLink('AdminSaferPayOfficialSettings'), 'adminToken' => Tools::getAdminTokenLite('AdminSaferPayOfficialSettings'), + + // Translations + 'translations' => $this->getSettingsTranslations(), ]; return $data; } + /** + * Get all translatable strings for the React frontend + */ + private function getSettingsTranslations() + { + /** @var \Invertus\SaferPay\Service\SettingsTranslationService $translationService */ + $translationService = $this->module->getService(\Invertus\SaferPay\Service\SettingsTranslationService::class); + + return $translationService->getAll(); + } + /** * Get order states for dropdown */ @@ -450,7 +530,7 @@ private function getCountries() { $countries = Country::getCountries($this->context->language->id, true); $result = []; - $result[] = ['id' => 0, 'name' => 'All']; + $result[] = ['id' => 0, 'name' => $this->module->l('All', self::FILE_NAME)]; foreach ($countries as $key => $country) { $result[] = [ 'id' => (int) $key, @@ -467,7 +547,7 @@ private function getCurrencies() { $currencies = Currency::getCurrencies(); $result = []; - $result[] = ['id' => 0, 'iso_code' => 'All']; + $result[] = ['id' => 0, 'iso_code' => $this->module->l('All', self::FILE_NAME)]; foreach ($currencies as $currency) { $result[] = [ 'id' => (int) $currency['id_currency'], @@ -487,7 +567,7 @@ private function getPaymentMethodsData() $obtainMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); $paymentMethods = $obtainMethods->obtainPaymentMethodsNamesAsArray(); } catch (SaferPayApiException $exception) { - return []; + return ['error' => $this->module->l('Failed to load payment methods. Please verify your API credentials.', self::FILE_NAME)]; } /** @var SaferPayPaymentRepository $paymentRepository */ @@ -543,10 +623,57 @@ private function getJsonInput() */ private function ajaxResponse($success, $message = '') { - $this->ajaxDie(json_encode([ + $this->sendJsonResponse([ 'success' => $success, 'message' => $message, - ])); + ]); + } + + /** + * Send JSON response and terminate (PS9 compatible) + */ + private function sendJsonResponse(array $data) + { + header('Content-Type: application/json'); + ob_end_clean(); + die(json_encode($data)); + } + + /** + * Parse customer ID from API username (format: PREFIX_CUSTOMERID_SUFFIX) + */ + private function parseCustomerIdFromUsername($username) + { + $parts = explode('_', (string) $username); + + return isset($parts[1]) ? $parts[1] : ''; + } + + /** + * Parse Saferpay API error message into user-friendly text + */ + private function parseApiErrorMessage($rawMessage) + { + $jsonStart = strpos($rawMessage, '{'); + if ($jsonStart !== false) { + $jsonString = substr($rawMessage, $jsonStart); + $decoded = json_decode($jsonString, true); + if (is_array($decoded) && !empty($decoded['ErrorName'])) { + $errorName = $decoded['ErrorName']; + if ($errorName === 'AUTHENTICATION_FAILED') { + return $this->module->l('Invalid API credentials. Please verify your username and password.', self::FILE_NAME); + } + + $message = $this->module->l('API validation failed:', self::FILE_NAME) . ' ' . $errorName; + if (!empty($decoded['ErrorMessage'])) { + $message .= ' — ' . $decoded['ErrorMessage']; + } + + return $message; + } + } + + return $this->module->l('API validation failed. Please check your credentials and try again.', self::FILE_NAME); } /** @@ -554,7 +681,7 @@ private function ajaxResponse($success, $message = '') */ private function getStringValue($data, $key) { - return isset($data[$key]) ? pSQL((string) $data[$key]) : ''; + return isset($data[$key]) ? (string) $data[$key] : ''; } /** diff --git a/src/DTO/Request/GetTerminals/GetTerminalsRequest.php b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php index 2d3b6b3f..6e32d45a 100644 --- a/src/DTO/Request/GetTerminals/GetTerminalsRequest.php +++ b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php @@ -37,6 +37,10 @@ class GetTerminalsRequest */ public function __construct($customerId) { + if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $customerId)) { + throw new \InvalidArgumentException('Invalid customer ID format'); + } + $this->customerId = $customerId; } diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php new file mode 100644 index 00000000..b5589198 --- /dev/null +++ b/src/Service/SettingsTranslationService.php @@ -0,0 +1,240 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Invertus\SaferPay\Factory\ModuleFactory; +use SaferPayOfficial; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SettingsTranslationService +{ + const FILE_NAME = 'SettingsTranslationService'; + + /** @var SaferPayOfficial */ + private $module; + + public function __construct(ModuleFactory $moduleFactory) + { + $this->module = $moduleFactory->getModule(); + } + + /** + * @return array + */ + public function getAll() + { + return array_merge( + $this->getAppTranslations(), + $this->getTabTranslations(), + $this->getCommonTranslations(), + $this->getApiCredentialsTranslations(), + $this->getPaymentMethodsTranslations(), + $this->getPaymentProcessingTranslations(), + $this->getEmailTranslations(), + $this->getGeneralSettingsTranslations(), + $this->getToastTranslations() + ); + } + + private function getAppTranslations() + { + return [ + 'saferpaySettings' => $this->module->l('Saferpay Settings', self::FILE_NAME), + 'configureIntegration' => $this->module->l('Configure your Saferpay payment integration for your Prestashop store.', self::FILE_NAME), + 'errorLoadingSettings' => $this->module->l('Something went wrong loading Saferpay settings. Please refresh the page.', self::FILE_NAME), + 'failedToLoadSettings' => $this->module->l('Failed to load settings data.', self::FILE_NAME), + ]; + } + + private function getTabTranslations() + { + return [ + 'tabApiCredentials' => $this->module->l('API Credentials', self::FILE_NAME), + 'tabPaymentMethods' => $this->module->l('Payment Methods', self::FILE_NAME), + 'tabPaymentProcessing' => $this->module->l('Payment Processing', self::FILE_NAME), + 'tabEmailNotifications' => $this->module->l('Email Notifications', self::FILE_NAME), + 'tabGeneralSettings' => $this->module->l('General Settings', self::FILE_NAME), + ]; + } + + private function getCommonTranslations() + { + return [ + 'saveChanges' => $this->module->l('Save Changes', self::FILE_NAME), + 'enable' => $this->module->l('Enable', self::FILE_NAME), + 'disable' => $this->module->l('Disable', self::FILE_NAME), + 'search' => $this->module->l('Search...', self::FILE_NAME), + 'noResultsFound' => $this->module->l('No results found.', self::FILE_NAME), + 'clearAll' => $this->module->l('Clear all', self::FILE_NAME), + 'selected' => $this->module->l('selected', self::FILE_NAME), + ]; + } + + private function getApiCredentialsTranslations() + { + return [ + 'environment' => $this->module->l('Environment', self::FILE_NAME), + 'envDescription' => $this->module->l('Select your active environment. Credentials are stored separately for each.', self::FILE_NAME), + 'selectEnvironment' => $this->module->l('Select environment', self::FILE_NAME), + 'testEnvironment' => $this->module->l('Test Environment', self::FILE_NAME), + 'liveEnvironment' => $this->module->l('Live Environment', self::FILE_NAME), + 'testModeWarning' => $this->module->l('You are currently in test mode. No real transactions will be processed.', self::FILE_NAME), + 'liveModeWarning' => $this->module->l('You are in live mode. Real transactions will be processed.', self::FILE_NAME), + 'test' => $this->module->l('Test', self::FILE_NAME), + 'live' => $this->module->l('Live', self::FILE_NAME), + 'apiCredentials' => $this->module->l('API Credentials', self::FILE_NAME), + 'enterSaferpayCredentials' => $this->module->l('Enter your Saferpay %s environment API credentials.', self::FILE_NAME), + 'jsonApiUsername' => $this->module->l('JSON API Username', self::FILE_NAME), + 'enterApiUsername' => $this->module->l('Enter %s API username', self::FILE_NAME), + 'jsonApiPassword' => $this->module->l('JSON API Password', self::FILE_NAME), + 'enterApiPassword' => $this->module->l('Enter %s API password', self::FILE_NAME), + 'hidePassword' => $this->module->l('Hide password', self::FILE_NAME), + 'showPassword' => $this->module->l('Show password', self::FILE_NAME), + 'terminalId' => $this->module->l('Terminal ID', self::FILE_NAME), + 'selectTerminal' => $this->module->l('Select a terminal', self::FILE_NAME), + 'refreshTerminals' => $this->module->l('Refresh terminals', self::FILE_NAME), + 'fetchTerminalsFromApi' => $this->module->l('Fetch terminals from API', self::FILE_NAME), + 'merchantEmails' => $this->module->l('Merchant Emails', self::FILE_NAME), + 'enterMerchantEmails' => $this->module->l('Enter merchant email addresses (comma-separated)', self::FILE_NAME), + 'separateEmails' => $this->module->l('Separate multiple email addresses with commas.', self::FILE_NAME), + 'saferpayFields' => $this->module->l('Saferpay Fields', self::FILE_NAME), + 'saferpayFieldsDescription' => $this->module->l('Configure Saferpay Fields for inline payment form integration.', self::FILE_NAME), + 'fieldAccessTokenInfo' => $this->module->l('Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to', self::FILE_NAME), + 'fieldAccessTokenPath' => $this->module->l('Settings > Saferpay Fields Access Tokens', self::FILE_NAME), + 'fieldAccessToken' => $this->module->l('Field Access Token', self::FILE_NAME), + 'enterFieldAccessToken' => $this->module->l('Enter field access token', self::FILE_NAME), + 'fieldJsUrl' => $this->module->l('Field Javascript Library URL', self::FILE_NAME), + 'businessLicense' => $this->module->l('I have Business license', self::FILE_NAME), + 'businessLicenseDescription' => $this->module->l('Enable if you have a Saferpay Business license for advanced features.', self::FILE_NAME), + ]; + } + + private function getPaymentMethodsTranslations() + { + return [ + 'paymentMethods' => $this->module->l('Payment Methods', self::FILE_NAME), + 'paymentMethodsDescription' => $this->module->l('Enable and configure available payment methods for your checkout.', self::FILE_NAME), + 'active' => $this->module->l('active', self::FILE_NAME), + 'paymentMethod' => $this->module->l('Payment method', self::FILE_NAME), + 'enabled' => $this->module->l('Enabled', self::FILE_NAME), + 'logos' => $this->module->l('Logos', self::FILE_NAME), + 'customForm' => $this->module->l('Custom form', self::FILE_NAME), + 'countries' => $this->module->l('Countries', self::FILE_NAME), + 'currencies' => $this->module->l('Currencies', self::FILE_NAME), + 'selectCountries' => $this->module->l('Select countries', self::FILE_NAME), + 'selectCurrencies' => $this->module->l('Select currencies', self::FILE_NAME), + 'select' => $this->module->l('Select', self::FILE_NAME), + 'noPaymentMethods' => $this->module->l('No payment methods available. Please configure your API credentials first.', self::FILE_NAME), + ]; + } + + private function getPaymentProcessingTranslations() + { + return [ + 'transactionHandling' => $this->module->l('Transaction Handling', self::FILE_NAME), + 'transactionHandlingDescription' => $this->module->l('Configure how payments are processed, authorized, and captured.', self::FILE_NAME), + 'defaultPaymentBehavior' => $this->module->l('Default payment behavior', self::FILE_NAME), + 'paymentBehaviorDescription' => $this->module->l('How payment provider should behave when order is created.', self::FILE_NAME), + 'capture' => $this->module->l('Capture', self::FILE_NAME), + 'chargeImmediately' => $this->module->l('Charge immediately', self::FILE_NAME), + 'authorize' => $this->module->l('Authorize', self::FILE_NAME), + 'reserveAndCaptureLater' => $this->module->l('Reserve and capture later', self::FILE_NAME), + 'behaviourWhen3dsFails' => $this->module->l('Behaviour when 3D Secure fails', self::FILE_NAME), + 'behaviourWhen3dsDescription' => $this->module->l('Default payment behavior for payment without 3-D Secure.', self::FILE_NAME), + 'cancel' => $this->module->l('Cancel', self::FILE_NAME), + 'rejectPayment' => $this->module->l('Reject the payment', self::FILE_NAME), + 'continueWithout3ds' => $this->module->l('Continue without 3DS', self::FILE_NAME), + 'restrictRefundAmount' => $this->module->l('Restrict RefundAmount to Captured Amount', self::FILE_NAME), + 'restrictRefundDescription' => $this->module->l('If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value.', self::FILE_NAME), + 'orderCreationRule' => $this->module->l('Order creation rule', self::FILE_NAME), + 'orderCreationDescription' => $this->module->l('Select the option to determine whether the order should be created.', self::FILE_NAME), + 'afterAuthorization' => $this->module->l('After authorization', self::FILE_NAME), + 'createWhenAuthorized' => $this->module->l('Create when authorized', self::FILE_NAME), + 'beforeAuthorization' => $this->module->l('Before authorization', self::FILE_NAME), + 'createBeforePayment' => $this->module->l('Create before payment', self::FILE_NAME), + 'cardDisplaySaving' => $this->module->l('Card Display & Saving', self::FILE_NAME), + 'cardDisplayDescription' => $this->module->l('Configure how cards appear at checkout and whether customers can save them.', self::FILE_NAME), + 'groupCardsLabel' => $this->module->l('Group debit/credit cards as \'Cards\' in checkout', self::FILE_NAME), + 'groupCardsDescription' => $this->module->l('If enabled, all supported card brands will be grouped and shown as a single \'Cards\' payment method at checkout.', self::FILE_NAME), + 'showCardsLogo' => $this->module->l('Show \'Cards\' payment method logo', self::FILE_NAME), + 'showCardsLogoDescription' => $this->module->l('If enabled, a logo for the grouped \'Cards\' payment method will be displayed at checkout.', self::FILE_NAME), + 'creditCardSaving' => $this->module->l('Credit card saving for customers', self::FILE_NAME), + 'creditCardSavingDescription' => $this->module->l('Allow customers to save credit card for faster purchase.', self::FILE_NAME), + ]; + } + + private function getEmailTranslations() + { + return [ + 'emailSending' => $this->module->l('Email Sending', self::FILE_NAME), + 'emailSendingDescription' => $this->module->l('Configure which emails are sent during the payment process.', self::FILE_NAME), + 'saferpayCustomerMail' => $this->module->l('Send an email from Saferpay on payment completion', self::FILE_NAME), + 'saferpayCustomerMailDescription' => $this->module->l('With this setting enabled an email from the Saferpay system will be sent to the customer.', self::FILE_NAME), + 'newOrderMail' => $this->module->l('Send new order mail on authorization', self::FILE_NAME), + 'newOrderMailDescription' => $this->module->l('Receive a notification when an order is authorized by Saferpay (Using the Mail alert module).', self::FILE_NAME), + 'orderConfMail' => $this->module->l('Send order confirmation mail on payment completion', self::FILE_NAME), + 'orderConfMailDescription' => $this->module->l('Send an email from Saferpay on payment completion.', self::FILE_NAME), + 'emailConfInfo' => $this->module->l('When this feature is enabled, a confirmation email will be only sent once the payment is authorized by Saferpay.', self::FILE_NAME), + 'emailConfMailAlert' => $this->module->l('For this feature to be functioning you need to have the Mail Alert module configured.', self::FILE_NAME), + ]; + } + + private function getGeneralSettingsTranslations() + { + return [ + 'orderState' => $this->module->l('Order State', self::FILE_NAME), + 'orderStateDescription' => $this->module->l('Define the default order status for Saferpay payments.', self::FILE_NAME), + 'statusAwaitingPayment' => $this->module->l('Status for Saferpay payment awaiting', self::FILE_NAME), + 'selectOrderStatus' => $this->module->l('Select order status', self::FILE_NAME), + 'defaultStatusDescription' => $this->module->l('Default status on SaferPay order creation.', self::FILE_NAME), + 'styling' => $this->module->l('Styling', self::FILE_NAME), + 'stylingDescription' => $this->module->l('Customize the appearance of the payment page.', self::FILE_NAME), + 'configName' => $this->module->l('Payment Page configurations name', self::FILE_NAME), + 'enterConfigName' => $this->module->l('Enter configuration name', self::FILE_NAME), + 'configNameDescription' => $this->module->l('This name is visible in payment page and also in payment confirmation email.', self::FILE_NAME), + 'configuration' => $this->module->l('Configuration', self::FILE_NAME), + 'configurationDescription' => $this->module->l('General module configuration settings.', self::FILE_NAME), + 'description' => $this->module->l('Description', self::FILE_NAME), + 'enterDescription' => $this->module->l('Enter description', self::FILE_NAME), + 'descriptionHelp' => $this->module->l('This description is visible in payment page also in payment confirmation email.', self::FILE_NAME), + 'debugMode' => $this->module->l('Debug mode', self::FILE_NAME), + 'debugModeDescription' => $this->module->l('Enable debug mode to see more information in logs.', self::FILE_NAME), + ]; + } + + private function getToastTranslations() + { + return [ + 'failedToFetchTerminals' => $this->module->l('Failed to fetch terminals', self::FILE_NAME), + 'errorFetchingTerminals' => $this->module->l('Error fetching terminals', self::FILE_NAME), + 'savedSuccessfully' => $this->module->l('%s saved successfully', self::FILE_NAME), + 'failedToSave' => $this->module->l('Failed to save %s', self::FILE_NAME), + 'errorSaving' => $this->module->l('Error saving %s: %s', self::FILE_NAME), + 'errorRefreshingPaymentMethods' => $this->module->l('Error refreshing payment methods: %s', self::FILE_NAME), + ]; + } +} diff --git a/views/js/admin/settings-app/src/App.tsx b/views/js/admin/settings-app/src/App.tsx index b2edbdd3..849fe5f5 100644 --- a/views/js/admin/settings-app/src/App.tsx +++ b/views/js/admin/settings-app/src/App.tsx @@ -1,10 +1,39 @@ +import React from 'react' import { SettingsProvider } from './context/settings-context' import { SaferpaySettings } from './components/settings/saferpay-settings' +import { t } from '@/utils/translations' + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode }) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return ( +
+ {t('errorLoadingSettings')} +
+ ) + } + return this.props.children + } +} export default function App() { return ( - - - + + + + + ) } diff --git a/views/js/admin/settings-app/src/api/client.ts b/views/js/admin/settings-app/src/api/client.ts index d75d6860..dac2fe45 100644 --- a/views/js/admin/settings-app/src/api/client.ts +++ b/views/js/admin/settings-app/src/api/client.ts @@ -1,13 +1,18 @@ import type { PaymentMethodData, TerminalOption } from '@/types' +interface AjaxResponse { + success: boolean + message?: string +} + function getConfig() { return window.saferpaySettingsData } -async function postAjax(action: string, data: Record = {}) { +async function postAjax(action: string, data: Record = {}): Promise { const config = getConfig() const separator = config.ajaxUrl.includes('?') ? '&' : '?' - const url = `${config.ajaxUrl}${separator}ajax=1&action=${action}` + const url = `${config.ajaxUrl}${separator}ajax=1&action=${action}&token=${encodeURIComponent(config.adminToken)}` const response = await fetch(url, { method: 'POST', @@ -22,26 +27,31 @@ async function postAjax(action: string, data: Record = {}) { throw new Error(`HTTP error ${response.status}`) } - return response.json() + const result = await response.json() + if (typeof result !== 'object' || result === null || typeof result.success !== 'boolean') { + throw new Error('Invalid response format') + } + + return result } -export async function saveCredentials(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveCredentials(data: Record): Promise { return postAjax('saveCredentials', data) } -export async function savePaymentProcessing(data: Record): Promise<{ success: boolean; message?: string }> { +export async function savePaymentProcessing(data: Record): Promise { return postAjax('savePaymentProcessing', data) } -export async function saveEmailSettings(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveEmailSettings(data: Record): Promise { return postAjax('saveEmailSettings', data) } -export async function saveGeneralSettings(data: Record): Promise<{ success: boolean; message?: string }> { +export async function saveGeneralSettings(data: Record): Promise { return postAjax('saveGeneralSettings', data) } -export async function savePaymentMethods(methods: PaymentMethodData[]): Promise<{ success: boolean; message?: string }> { +export async function savePaymentMethods(methods: PaymentMethodData[]): Promise { return postAjax('savePaymentMethods', { paymentMethods: methods }) } @@ -49,11 +59,13 @@ export async function getTerminals( env: string, username: string, password: string, - customerId: string, ): Promise<{ success: boolean; terminals: TerminalOption[] }> { - return postAjax('getTerminals', { env, username, password, customerId }) + return postAjax('getTerminals', { env, username, password }) as Promise<{ + success: boolean + terminals: TerminalOption[] + }> } export async function refreshData(): Promise<{ success: boolean; data: Record }> { - return postAjax('refreshData') + return postAjax('refreshData') as Promise<{ success: boolean; data: Record }> } diff --git a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx index 09d0bbfa..2dccfa0e 100644 --- a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' @@ -7,10 +7,12 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { AlertCircle, Eye, EyeOff, Key, Shield, Loader2, Info } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' import type { TerminalOption } from '@/types' export function ApiCredentials() { - const { settings, updateSettings, saveCredentials, fetchTerminals, saving } = useSettings() + const { settings, updateSettings, saveCredentials, fetchTerminals, savingSections } = useSettings() + const saving = savingSections.has('credentials') const [showApiPassword, setShowApiPassword] = useState(false) const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) const [terminals, setTerminals] = useState([]) @@ -20,7 +22,6 @@ export function ApiCredentials() { const username = isTest ? settings.testUsername : settings.liveUsername const password = isTest ? settings.testPassword : settings.livePassword - const customerId = isTest ? settings.testCustomerId : settings.liveCustomerId const terminalId = isTest ? settings.testTerminalId : settings.liveTerminalId const merchantEmails = isTest ? settings.testMerchantEmails : settings.liveMerchantEmails const fieldAccessToken = isTest ? settings.testFieldAccessToken : settings.liveFieldAccessToken @@ -32,25 +33,40 @@ export function ApiCredentials() { updateSettings({ [`${prefix}${field.charAt(0).toUpperCase() + field.slice(1)}`]: value } as Record) } - const envLabel = isTest ? 'Test' : 'Live' + const envLabel = isTest ? t('test') : t('live') const hasCredentials = username.length > 0 && password.length > 0 - const handleFetchTerminals = async () => { - if (!hasCredentials) return + const handleFetchTerminals = useCallback(async () => { + if (!username || !password) return setIsLoadingTerminals(true) try { - const result = await fetchTerminals(environment, username, password, customerId) + const result = await fetchTerminals(environment, username, password) setTerminals(result) } finally { setIsLoadingTerminals(false) } - } + }, [environment, username, password, fetchTerminals]) + // Reset terminals when switching environment useEffect(() => { + setTerminals([]) + }, [environment]) + + // Auto-fetch terminals when credentials are complete (debounced) + const fetchTimerRef = useRef | null>(null) + useEffect(() => { + if (fetchTimerRef.current) { + clearTimeout(fetchTimerRef.current) + } if (hasCredentials && terminals.length === 0) { - handleFetchTerminals() + fetchTimerRef.current = setTimeout(() => { + handleFetchTerminals() + }, 800) } - }, [environment]) + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, [hasCredentials, environment]) // eslint-disable-line react-hooks/exhaustive-deps return (
@@ -59,29 +75,29 @@ export function ApiCredentials() {
- Environment + {t('environment')} - Select your active environment. Credentials are stored separately for each. + {t('envDescription')}
setField('username', e.target.value)} />
- +
setField('password', e.target.value)} className="sp-pr-10" @@ -151,7 +167,7 @@ export function ApiCredentials() { type="button" onClick={() => setShowApiPassword(!showApiPassword)} className="sp-absolute sp-right-3 sp-top-1/2 sp--translate-y-1/2 sp-text-muted-foreground hover:sp-text-foreground sp-transition-colors" - aria-label={showApiPassword ? 'Hide password' : 'Show password'} + aria-label={showApiPassword ? t('hidePassword') : t('showPassword')} > {showApiPassword ? : } @@ -159,71 +175,57 @@ export function ApiCredentials() {
-
-
- - setField('customerId', e.target.value)} - /> -
-
- -
- setField('terminalId', val)} + > + + + + + {terminals.map((terminal) => ( + {terminal.name} + ))} + {terminalId && terminals.length === 0 && ( + {terminalId} + )} + + + + +
- {!hasCredentials && ( -

- Enter your API username and password first to load available terminals. -

- )}
-
+ )}
- + setField('merchantEmails', e.target.value)} />

- Separate multiple email addresses with commas. + {t('separateEmails')}

@@ -233,9 +235,9 @@ export function ApiCredentials() { {/* Saferpay Fields Configuration */} - Saferpay Fields + {t('saferpayFields')} - Configure Saferpay Fields for inline payment form integration. + {t('saferpayFieldsDescription')} @@ -243,24 +245,24 @@ export function ApiCredentials() {

- Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to{' '} - {'Settings > Saferpay Fields Access Tokens'}. + {t('fieldAccessTokenInfo')}{' '} + {t('fieldAccessTokenPath')}.

- + setField('fieldAccessToken', e.target.value)} />
- +

- Enable if you have a Saferpay Business license for advanced features. + {t('businessLicenseDescription')}

diff --git a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx index 7c5b702d..044239c8 100644 --- a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx +++ b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx @@ -4,9 +4,11 @@ import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Mail, Info, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function EmailNotifications() { - const { settings, updateSettings, saveEmailSettings, saving } = useSettings() + const { settings, updateSettings, saveEmailSettings, savingSections } = useSettings() + const saving = savingSections.has('emailSettings') return (
@@ -15,9 +17,9 @@ export function EmailNotifications() {
- Email Sending + {t('emailSending')} - Configure which emails are sent during the payment process. + {t('emailSendingDescription')}
@@ -27,10 +29,10 @@ export function EmailNotifications() {

- With this setting enabled an email from the Saferpay system will be sent to the customer. + {t('saferpayCustomerMailDescription')}

- Receive a notification when an order is authorized by Saferpay (Using the Mail alert module). + {t('newOrderMailDescription')}

- Send an email from Saferpay on payment completion. + {t('orderConfMailDescription')}

- When this feature is enabled, a confirmation email will be only sent once the payment is authorized by Saferpay. + {t('emailConfInfo')}

- For this feature to be functioning you need to have the Mail Alert module configured. + {t('emailConfMailAlert')}

@@ -89,7 +91,7 @@ export function EmailNotifications() {
diff --git a/views/js/admin/settings-app/src/components/settings/general-settings.tsx b/views/js/admin/settings-app/src/components/settings/general-settings.tsx index 6f6ff92e..70f74b73 100644 --- a/views/js/admin/settings-app/src/components/settings/general-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -6,9 +6,11 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Settings2, Paintbrush, ClipboardList, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function GeneralSettings() { - const { settings, updateSettings, saveGeneralSettings, saving } = useSettings() + const { settings, updateSettings, saveGeneralSettings, savingSections } = useSettings() + const saving = savingSections.has('generalSettings') return (
@@ -18,22 +20,22 @@ export function GeneralSettings() {
- Order State + {t('orderState')} - Define the default order status for Saferpay payments. + {t('orderStateDescription')}
- +

- Default status on SaferPay order creation. + {t('defaultStatusDescription')}

@@ -56,25 +58,25 @@ export function GeneralSettings() {
- Styling + {t('styling')} - Customize the appearance of the payment page. + {t('stylingDescription')}
- + updateSettings({ configurationName: e.target.value })} />

- This name is visible in payment page and also in payment confirmation email. + {t('configNameDescription')}

@@ -86,9 +88,9 @@ export function GeneralSettings() {
- Configuration + {t('configuration')} - General module configuration settings. + {t('configurationDescription')}
@@ -96,26 +98,26 @@ export function GeneralSettings() {
- + updateSettings({ paymentDescription: e.target.value })} />

- This description is visible in payment page also in payment confirmation email. + {t('descriptionHelp')}

- Enable debug mode to see more information in logs. + {t('debugModeDescription')}

diff --git a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx index 48f31d8d..bc3fc449 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx @@ -9,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Wallet, ChevronDown, Search, Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' function MultiSelect({ options, @@ -64,7 +65,7 @@ function MultiSelect({ }) ) : ( - {selected.length} selected + {selected.length} {t('selected')} )} @@ -79,7 +80,7 @@ function MultiSelect({ setSearch(e.target.value)} - placeholder="Search..." + placeholder={t('search')} className="sp-h-8 sp-pl-7 sp-text-xs" />
@@ -99,7 +100,7 @@ function MultiSelect({ ))} {filteredOptions.length === 0 && (

- No results found. + {t('noResultsFound')}

)} @@ -110,7 +111,7 @@ function MultiSelect({ onClick={() => onChange([])} className="sp-w-full sp-rounded-sm sp-px-2 sp-py-1 sp-text-xs sp-text-muted-foreground hover:sp-text-foreground sp-transition-colors" > - Clear all + {t('clearAll')} )} @@ -120,13 +121,14 @@ function MultiSelect({ } export function PaymentMethods() { - const { paymentMethods, updatePaymentMethod, savePaymentMethods, saving, settings, refreshPaymentMethods } = useSettings() + const { paymentMethods, updatePaymentMethod, savePaymentMethods, savingSections, settings, refreshPaymentMethods } = useSettings() + const saving = savingSections.has('paymentMethods') useEffect(() => { if (paymentMethods.length === 0) { refreshPaymentMethods() } - }, []) + }, [paymentMethods.length, refreshPaymentMethods]) const enabledCount = paymentMethods.filter((m) => m.enabled).length @@ -138,15 +140,15 @@ export function PaymentMethods() {
- Payment Methods + {t('paymentMethods')} - Enable and configure available payment methods for your checkout. + {t('paymentMethodsDescription')}
{enabledCount > 0 && ( - {enabledCount} active + {enabledCount} {t('active')} )} @@ -154,12 +156,12 @@ export function PaymentMethods() { {/* Header row */}
- Payment method - Enabled - Logos - Custom form - Countries - Currencies + {t('paymentMethod')} + {t('enabled')} + {t('logos')} + {t('customForm')} + {t('countries')} + {t('currencies')}
{/* Payment method rows */} @@ -183,7 +185,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { enabled: checked })} - aria-label={`Enable ${method.displayName}`} + aria-label={`${t('enable')} ${method.displayName}`} /> @@ -191,7 +193,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { showLogos: checked })} - aria-label={`Show logos for ${method.displayName}`} + aria-label={`${t('logos')} ${method.displayName}`} /> @@ -200,7 +202,7 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { showCustomForm: checked })} - aria-label={`Show custom form for ${method.displayName}`} + aria-label={`${t('customForm')} ${method.displayName}`} /> ) : ( -- @@ -211,16 +213,16 @@ export function PaymentMethods() { options={settings.countries} selected={method.countries} onChange={(countries) => updatePaymentMethod(method.name, { countries })} - placeholder="Select countries" - label={`Countries for ${method.displayName}`} + placeholder={t('selectCountries')} + label={`${t('countries')} ${method.displayName}`} /> ({ id: c.id, name: c.iso_code }))} selected={method.currencies} onChange={(currencies) => updatePaymentMethod(method.name, { currencies })} - placeholder="Select currencies" - label={`Currencies for ${method.displayName}`} + placeholder={t('selectCurrencies')} + label={`${t('currencies')} ${method.displayName}`} /> @@ -231,13 +233,13 @@ export function PaymentMethods() { updatePaymentMethod(method.name, { enabled: checked })} - aria-label={`Enable ${method.displayName}`} + aria-label={`${t('enable')} ${method.displayName}`} />
- + updatePaymentMethod(method.name, { showLogos: checked })} @@ -245,7 +247,7 @@ export function PaymentMethods() {
{method.hasCustomForm && (
- + updatePaymentMethod(method.name, { showCustomForm: checked })} @@ -256,23 +258,23 @@ export function PaymentMethods() {
- + updatePaymentMethod(method.name, { countries })} - placeholder="Select" - label={`Countries for ${method.displayName}`} + placeholder={t('select')} + label={`${t('countries')} ${method.displayName}`} />
- + ({ id: c.id, name: c.iso_code }))} selected={method.currencies} onChange={(currencies) => updatePaymentMethod(method.name, { currencies })} - placeholder="Select" - label={`Currencies for ${method.displayName}`} + placeholder={t('select')} + label={`${t('currencies')} ${method.displayName}`} />
@@ -282,18 +284,20 @@ export function PaymentMethods() { {paymentMethods.length === 0 && (
- No payment methods available. Please configure your API credentials first. + {t('noPaymentMethods')}
)}
-
- -
+ {paymentMethods.length > 0 && ( +
+ +
+ )}
) } diff --git a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx index c57c9034..a24ad5ba 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx @@ -5,9 +5,11 @@ import { Switch } from '@/components/ui/switch' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { CreditCard, ShieldCheck, Loader2 } from 'lucide-react' import { useSettings } from '@/context/settings-context' +import { t } from '@/utils/translations' export function PaymentProcessing() { - const { settings, updateSettings, savePaymentProcessing, saving } = useSettings() + const { settings, updateSettings, savePaymentProcessing, savingSections } = useSettings() + const saving = savingSections.has('paymentProcessing') return (
@@ -17,9 +19,9 @@ export function PaymentProcessing() {
- Transaction Handling + {t('transactionHandling')} - Configure how payments are processed, authorized, and captured. + {t('transactionHandlingDescription')}
@@ -29,9 +31,9 @@ export function PaymentProcessing() { {/* Default Payment Behavior */}
- +

- How payment provider should behave when order is created. + {t('paymentBehaviorDescription')}

- Capture - Charge immediately + {t('capture')} + {t('chargeImmediately')}
@@ -73,9 +75,9 @@ export function PaymentProcessing() { {/* 3D Secure Behavior */}
- +

- Default payment behavior for payment without 3-D Secure. + {t('behaviourWhen3dsDescription')}

- Cancel - Reject the payment + {t('cancel')} + {t('rejectPayment')}
@@ -117,9 +119,9 @@ export function PaymentProcessing() { {/* Restrict Refund */}
- +

- If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value. + {t('restrictRefundDescription')}

- Enable + {t('enable')}
@@ -155,9 +157,9 @@ export function PaymentProcessing() { {/* Order Creation Rule */}
- +

- Select the option to determine whether the order should be created. + {t('orderCreationDescription')}

- After authorization - Create when authorized + {t('afterAuthorization')} + {t('createWhenAuthorized')}
@@ -205,9 +207,9 @@ export function PaymentProcessing() {
- Card Display & Saving + {t('cardDisplaySaving')} - Configure how cards appear at checkout and whether customers can save them. + {t('cardDisplayDescription')}
@@ -217,10 +219,10 @@ export function PaymentProcessing() {

- {"If enabled, all supported card brands will be grouped and shown as a single 'Cards' payment method at checkout."} + {t('groupCardsDescription')}

-
-
- -

- {"If enabled, a logo for the grouped 'Cards' payment method will be displayed at checkout."} -

+ {settings.groupCards && ( +
+
+ +

+ {t('showCardsLogoDescription')} +

+
+
-
+ )}
- +

- Allow customers to save credit card for faster purchase. + {t('creditCardSavingDescription')}

- Enable + {t('enable')}
@@ -288,7 +292,7 @@ export function PaymentProcessing() {
diff --git a/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx b/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx index 6b232221..0d864991 100644 --- a/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx @@ -1,3 +1,4 @@ +import { t } from '@/utils/translations' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ApiCredentials } from './api-credentials' import { PaymentProcessing } from './payment-processing' @@ -12,10 +13,10 @@ export function SaferpaySettings() {

- Saferpay Settings + {t('saferpaySettings')}

- Configure your Saferpay payment integration for your Prestashop store. + {t('configureIntegration')}

@@ -26,35 +27,35 @@ export function SaferpaySettings() { className="sp-flex sp-items-center sp-gap-2 sp-rounded-md sp-px-2.5 sp-py-2.5 sm:sp-px-4 sp-text-sm sp-font-normal sp-bg-transparent data-[state=active]:sp-bg-primary data-[state=active]:sp-text-primary-foreground data-[state=active]:sp-shadow-none" > - API Credentials + {t('tabApiCredentials')} - Payment Methods + {t('tabPaymentMethods')} - Payment Processing + {t('tabPaymentProcessing')} - Email Notifications + {t('tabEmailNotifications')} - General Settings + {t('tabGeneralSettings')} diff --git a/views/js/admin/settings-app/src/components/settings/toast-container.tsx b/views/js/admin/settings-app/src/components/settings/toast-container.tsx index b1162ab3..39d96074 100644 --- a/views/js/admin/settings-app/src/components/settings/toast-container.tsx +++ b/views/js/admin/settings-app/src/components/settings/toast-container.tsx @@ -3,17 +3,19 @@ import { useToast } from '@/hooks/use-toast' export function ToastContainer() { const { toasts } = useToast() - if (toasts.length === 0) return null - return ( -
+
{toasts.map((t) => (
{t.title &&
{t.title}
} diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index b20e78fd..29e0b3fa 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -1,7 +1,10 @@ -import React, { createContext, useContext, useState, useCallback } from 'react' +import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react' import type { SaferpaySettingsData, PaymentMethodData, TerminalOption } from '@/types' import * as api from '@/api/client' import { toast } from '@/hooks/use-toast' +import { t } from '@/utils/translations' + +type SavingSection = 'credentials' | 'paymentProcessing' | 'emailSettings' | 'generalSettings' | 'paymentMethods' interface SettingsContextValue { settings: SaferpaySettingsData @@ -11,19 +14,28 @@ interface SettingsContextValue { saveEmailSettings: () => Promise saveGeneralSettings: () => Promise savePaymentMethods: () => Promise - fetchTerminals: (env: string, username: string, password: string, customerId: string) => Promise + fetchTerminals: (env: string, username: string, password: string) => Promise refreshPaymentMethods: () => Promise paymentMethods: PaymentMethodData[] updatePaymentMethod: (name: string, updates: Partial) => void - saving: boolean + savingSections: Set } const SettingsContext = createContext(null) export function SettingsProvider({ children }: { children: React.ReactNode }) { const [settings, setSettings] = useState(() => window.saferpaySettingsData) - const [paymentMethods, setPaymentMethods] = useState(() => window.saferpaySettingsData.paymentMethods || []) - const [saving, setSaving] = useState(false) + const [paymentMethods, setPaymentMethods] = useState(() => { + const methods = window.saferpaySettingsData.paymentMethods + return Array.isArray(methods) ? methods : [] + }) + const [savingSections, setSavingSections] = useState>(new Set()) + + const settingsRef = useRef(settings) + settingsRef.current = settings + + const paymentMethodsRef = useRef(paymentMethods) + paymentMethodsRef.current = paymentMethods const updateSettings = useCallback((updates: Partial) => { setSettings((prev) => ({ ...prev, ...updates })) @@ -35,119 +47,151 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { ) }, []) - const handleSave = useCallback(async (saveFn: () => Promise<{ success: boolean; message?: string }>, label: string) => { - setSaving(true) + const handleSave = useCallback(async ( + saveFn: () => Promise<{ success: boolean; message?: string }>, + label: string, + section: SavingSection, + ) => { + setSavingSections((prev) => new Set(prev).add(section)) try { const result = await saveFn() if (result.success) { - toast({ title: `${label} saved successfully`, variant: 'default' }) + toast({ title: result.message || t('savedSuccessfully', label), variant: 'default' }) } else { - toast({ title: result.message || `Failed to save ${label}`, variant: 'destructive' }) + toast({ title: result.message || t('failedToSave', label), variant: 'destructive' }) } } catch (e) { - toast({ title: `Error saving ${label}`, variant: 'destructive' }) + const message = e instanceof Error ? e.message : 'Unknown error' + toast({ title: t('errorSaving', label, message), variant: 'destructive' }) } finally { - setSaving(false) + setSavingSections((prev) => { + const next = new Set(prev) + next.delete(section) + return next + }) } }, []) const saveCredentials = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.saveCredentials({ - testMode: settings.testMode, - testUsername: settings.testUsername, - testPassword: settings.testPassword, - testCustomerId: settings.testCustomerId, - testTerminalId: settings.testTerminalId, - testMerchantEmails: settings.testMerchantEmails, - testFieldAccessToken: settings.testFieldAccessToken, - testFieldJsUrl: settings.testFieldJsUrl, - testBusinessLicense: settings.testBusinessLicense, - liveUsername: settings.liveUsername, - livePassword: settings.livePassword, - liveCustomerId: settings.liveCustomerId, - liveTerminalId: settings.liveTerminalId, - liveMerchantEmails: settings.liveMerchantEmails, - liveFieldAccessToken: settings.liveFieldAccessToken, - liveFieldJsUrl: settings.liveFieldJsUrl, - liveBusinessLicense: settings.liveBusinessLicense, - }), 'API Credentials') - }, [settings, handleSave]) + testMode: s.testMode, + testUsername: s.testUsername, + testPassword: s.testPassword, + testTerminalId: s.testTerminalId, + testMerchantEmails: s.testMerchantEmails, + testFieldAccessToken: s.testFieldAccessToken, + testFieldJsUrl: s.testFieldJsUrl, + testBusinessLicense: s.testBusinessLicense, + liveUsername: s.liveUsername, + livePassword: s.livePassword, + liveTerminalId: s.liveTerminalId, + liveMerchantEmails: s.liveMerchantEmails, + liveFieldAccessToken: s.liveFieldAccessToken, + liveFieldJsUrl: s.liveFieldJsUrl, + liveBusinessLicense: s.liveBusinessLicense, + }), 'API Credentials', 'credentials') + }, [handleSave]) const savePaymentProcessingFn = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.savePaymentProcessing({ - paymentBehavior: settings.paymentBehavior, - paymentBehaviorWithout3D: settings.paymentBehaviorWithout3D, - restrictRefund: settings.restrictRefund, - orderCreationAfterAuth: settings.orderCreationAfterAuth, - groupCards: settings.groupCards, - groupCardsLogo: settings.groupCardsLogo, - creditCardSave: settings.creditCardSave, - }), 'Payment Processing') - }, [settings, handleSave]) + paymentBehavior: s.paymentBehavior, + paymentBehaviorWithout3D: s.paymentBehaviorWithout3D, + restrictRefund: s.restrictRefund, + orderCreationAfterAuth: s.orderCreationAfterAuth, + groupCards: s.groupCards, + groupCardsLogo: s.groupCardsLogo, + creditCardSave: s.creditCardSave, + }), 'Payment Processing', 'paymentProcessing') + }, [handleSave]) const saveEmailSettingsFn = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.saveEmailSettings({ - allowSaferpayMail: settings.allowSaferpayMail, - sendNewOrderMail: settings.sendNewOrderMail, - sendOrderConfMail: settings.sendOrderConfMail, - }), 'Email Settings') - }, [settings, handleSave]) + allowSaferpayMail: s.allowSaferpayMail, + sendNewOrderMail: s.sendNewOrderMail, + sendOrderConfMail: s.sendOrderConfMail, + }), 'Email Settings', 'emailSettings') + }, [handleSave]) const saveGeneralSettingsFn = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.saveGeneralSettings({ - orderStateAwaitingPayment: settings.orderStateAwaitingPayment, - paymentDescription: settings.paymentDescription, - configurationName: settings.configurationName, - debugMode: settings.debugMode, - }), 'General Settings') - }, [settings, handleSave]) + orderStateAwaitingPayment: s.orderStateAwaitingPayment, + paymentDescription: s.paymentDescription, + configurationName: s.configurationName, + debugMode: s.debugMode, + }), 'General Settings', 'generalSettings') + }, [handleSave]) const savePaymentMethodsFn = useCallback(async () => { - await handleSave(() => api.savePaymentMethods(paymentMethods), 'Payment Methods') - }, [paymentMethods, handleSave]) + await handleSave( + () => api.savePaymentMethods(paymentMethodsRef.current), + 'Payment Methods', + 'paymentMethods', + ) + }, [handleSave]) const refreshPaymentMethods = useCallback(async () => { try { const result = await api.refreshData() if (result.success && result.data?.paymentMethods) { - setPaymentMethods(result.data.paymentMethods as PaymentMethodData[]) + const methods = result.data.paymentMethods + if (Array.isArray(methods)) { + setPaymentMethods(methods as PaymentMethodData[]) + } } - } catch { - // silently fail, user still has initial data + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error' + toast({ title: t('errorRefreshingPaymentMethods', message), variant: 'destructive' }) } }, []) - const fetchTerminals = useCallback(async (env: string, username: string, password: string, customerId: string) => { + const fetchTerminals = useCallback(async (env: string, username: string, password: string) => { try { - const result = await api.getTerminals(env, username, password, customerId) + const result = await api.getTerminals(env, username, password) if (result.success) { return result.terminals } - toast({ title: 'Failed to fetch terminals', variant: 'destructive' }) + toast({ title: t('failedToFetchTerminals'), variant: 'destructive' }) return [] } catch { - toast({ title: 'Error fetching terminals', variant: 'destructive' }) + toast({ title: t('errorFetchingTerminals'), variant: 'destructive' }) return [] } }, []) + const value = useMemo(() => ({ + settings, + updateSettings, + saveCredentials, + savePaymentProcessing: savePaymentProcessingFn, + saveEmailSettings: saveEmailSettingsFn, + saveGeneralSettings: saveGeneralSettingsFn, + savePaymentMethods: savePaymentMethodsFn, + fetchTerminals, + refreshPaymentMethods, + paymentMethods, + updatePaymentMethod, + savingSections, + }), [ + settings, + updateSettings, + saveCredentials, + savePaymentProcessingFn, + saveEmailSettingsFn, + saveGeneralSettingsFn, + savePaymentMethodsFn, + fetchTerminals, + refreshPaymentMethods, + paymentMethods, + updatePaymentMethod, + savingSections, + ]) + return ( - + {children} ) diff --git a/views/js/admin/settings-app/src/hooks/use-mobile.tsx b/views/js/admin/settings-app/src/hooks/use-mobile.tsx deleted file mode 100644 index 4331d5c5..00000000 --- a/views/js/admin/settings-app/src/hooks/use-mobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile -} diff --git a/views/js/admin/settings-app/src/hooks/use-toast.ts b/views/js/admin/settings-app/src/hooks/use-toast.ts index a0dadc74..ec210341 100644 --- a/views/js/admin/settings-app/src/hooks/use-toast.ts +++ b/views/js/admin/settings-app/src/hooks/use-toast.ts @@ -91,7 +91,7 @@ function useToast() { const index = listeners.indexOf(setState) if (index > -1) listeners.splice(index, 1) } - }, [state]) + }, []) return { ...state, diff --git a/views/js/admin/settings-app/src/main.tsx b/views/js/admin/settings-app/src/main.tsx index be0a207e..273674a8 100644 --- a/views/js/admin/settings-app/src/main.tsx +++ b/views/js/admin/settings-app/src/main.tsx @@ -2,14 +2,36 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './globals.css' +import type { SaferpaySettingsData } from '@/types' +import { initTranslations } from '@/utils/translations' + +function parseSettingsData(): SaferpaySettingsData | null { + const el = document.getElementById('saferpay-settings-data') + if (!el?.textContent) return null + + try { + return JSON.parse(el.textContent) as SaferpaySettingsData + } catch { + return null + } +} document.addEventListener('DOMContentLoaded', () => { const rootEl = document.getElementById('saferpay-settings-root') - if (rootEl) { - ReactDOM.createRoot(rootEl).render( - - - , - ) + if (!rootEl) return + + const data = parseSettingsData() + if (!data) { + rootEl.innerHTML = '
Failed to load settings data.
' + return } + + window.saferpaySettingsData = data + initTranslations(data.translations || {}) + + ReactDOM.createRoot(rootEl).render( + + + , + ) }) diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index a6a598df..ad8d82aa 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -21,7 +21,6 @@ export interface SaferpaySettingsData { // Test credentials testUsername: string testPassword: string - testCustomerId: string testTerminalId: string testMerchantEmails: string testFieldAccessToken: string @@ -31,7 +30,6 @@ export interface SaferpaySettingsData { // Live credentials liveUsername: string livePassword: string - liveCustomerId: string liveTerminalId: string liveMerchantEmails: string liveFieldAccessToken: string @@ -67,6 +65,9 @@ export interface SaferpaySettingsData { // Endpoints ajaxUrl: string adminToken: string + + // Translations + translations: Record } declare global { diff --git a/views/js/admin/settings-app/src/utils/translations.ts b/views/js/admin/settings-app/src/utils/translations.ts new file mode 100644 index 00000000..131018d0 --- /dev/null +++ b/views/js/admin/settings-app/src/utils/translations.ts @@ -0,0 +1,13 @@ +let translations: Record = {} + +export function initTranslations(t: Record) { + translations = t +} + +export function t(key: string, ...args: (string | number)[]): string { + let str = translations[key] || key + args.forEach((arg) => { + str = str.replace('%s', String(arg)) + }) + return str +} diff --git a/views/templates/admin/settings_react.tpl b/views/templates/admin/settings_react.tpl index 34c79ee4..5376b30b 100644 --- a/views/templates/admin/settings_react.tpl +++ b/views/templates/admin/settings_react.tpl @@ -1,4 +1,2 @@
- + From 0b3664c1ce778341eec7ec6fe4b6203b81e3f162 Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Thu, 12 Mar 2026 16:57:00 +0200 Subject: [PATCH 02/30] rename variable --- .../src/context/settings-context.tsx | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 29e0b3fa..57e9af8b 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -73,55 +73,55 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { }, []) const saveCredentials = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.saveCredentials({ - testMode: s.testMode, - testUsername: s.testUsername, - testPassword: s.testPassword, - testTerminalId: s.testTerminalId, - testMerchantEmails: s.testMerchantEmails, - testFieldAccessToken: s.testFieldAccessToken, - testFieldJsUrl: s.testFieldJsUrl, - testBusinessLicense: s.testBusinessLicense, - liveUsername: s.liveUsername, - livePassword: s.livePassword, - liveTerminalId: s.liveTerminalId, - liveMerchantEmails: s.liveMerchantEmails, - liveFieldAccessToken: s.liveFieldAccessToken, - liveFieldJsUrl: s.liveFieldJsUrl, - liveBusinessLicense: s.liveBusinessLicense, + testMode: currentSettings.testMode, + testUsername: currentSettings.testUsername, + testPassword: currentSettings.testPassword, + testTerminalId: currentSettings.testTerminalId, + testMerchantEmails: currentSettings.testMerchantEmails, + testFieldAccessToken: currentSettings.testFieldAccessToken, + testFieldJsUrl: currentSettings.testFieldJsUrl, + testBusinessLicense: currentSettings.testBusinessLicense, + liveUsername: currentSettings.liveUsername, + livePassword: currentSettings.livePassword, + liveTerminalId: currentSettings.liveTerminalId, + liveMerchantEmails: currentSettings.liveMerchantEmails, + liveFieldAccessToken: currentSettings.liveFieldAccessToken, + liveFieldJsUrl: currentSettings.liveFieldJsUrl, + liveBusinessLicense: currentSettings.liveBusinessLicense, }), 'API Credentials', 'credentials') }, [handleSave]) const savePaymentProcessingFn = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.savePaymentProcessing({ - paymentBehavior: s.paymentBehavior, - paymentBehaviorWithout3D: s.paymentBehaviorWithout3D, - restrictRefund: s.restrictRefund, - orderCreationAfterAuth: s.orderCreationAfterAuth, - groupCards: s.groupCards, - groupCardsLogo: s.groupCardsLogo, - creditCardSave: s.creditCardSave, + paymentBehavior: currentSettings.paymentBehavior, + paymentBehaviorWithout3D: currentSettings.paymentBehaviorWithout3D, + restrictRefund: currentSettings.restrictRefund, + orderCreationAfterAuth: currentSettings.orderCreationAfterAuth, + groupCards: currentSettings.groupCards, + groupCardsLogo: currentSettings.groupCardsLogo, + creditCardSave: currentSettings.creditCardSave, }), 'Payment Processing', 'paymentProcessing') }, [handleSave]) const saveEmailSettingsFn = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.saveEmailSettings({ - allowSaferpayMail: s.allowSaferpayMail, - sendNewOrderMail: s.sendNewOrderMail, - sendOrderConfMail: s.sendOrderConfMail, + allowSaferpayMail: currentSettings.allowSaferpayMail, + sendNewOrderMail: currentSettings.sendNewOrderMail, + sendOrderConfMail: currentSettings.sendOrderConfMail, }), 'Email Settings', 'emailSettings') }, [handleSave]) const saveGeneralSettingsFn = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.saveGeneralSettings({ - orderStateAwaitingPayment: s.orderStateAwaitingPayment, - paymentDescription: s.paymentDescription, - configurationName: s.configurationName, - debugMode: s.debugMode, + orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, + paymentDescription: currentSettings.paymentDescription, + configurationName: currentSettings.configurationName, + debugMode: currentSettings.debugMode, }), 'General Settings', 'generalSettings') }, [handleSave]) From cc7cc0973d68cfcaff3ecaae73f5dd446eea47f6 Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Sun, 15 Mar 2026 11:49:00 +0200 Subject: [PATCH 03/30] fix: fixing styles and credentials saving logic --- ...dminSaferPayOfficialSettingsController.php | 74 ++--- src/Service/SettingsTranslationService.php | 21 +- views/js/admin/settings-app/src/api/client.ts | 3 +- .../components/settings/api-credentials.tsx | 254 ++++++++++-------- .../src/context/settings-context.tsx | 132 +++++---- 5 files changed, 268 insertions(+), 216 deletions(-) diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index 2b0ad907..bccaea21 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -29,6 +29,7 @@ use Invertus\SaferPay\Repository\SaferPaySavedCreditCardRepository; use Invertus\SaferPay\Adapter\Configuration as SaferPayConfiguration; use Invertus\SaferPay\Service\SaferPayFieldCreator; +use Invertus\SaferPay\Service\SaferPayGetLicense; use Invertus\SaferPay\Service\SaferPayGetTerminals; use Invertus\SaferPay\Service\SaferPayLogoCreator; use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; @@ -190,7 +191,6 @@ public function ajaxProcessSaveCredentials() $configuration->set(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testFieldAccessToken')); $configuration->set(SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, 'testFieldJsUrl')); - $configuration->set(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX, !empty($data['testBusinessLicense']) ? 1 : 0); // Live credentials $liveUsername = $this->getStringValue($data, 'liveUsername'); @@ -204,28 +204,40 @@ public function ajaxProcessSaveCredentials() $configuration->set(SaferPayConfig::MERCHANT_EMAILS, $this->getStringValue($data, 'liveMerchantEmails')); $configuration->set(SaferPayConfig::FIELDS_ACCESS_TOKEN, $this->getStringValue($data, 'liveFieldAccessToken')); $configuration->set(SaferPayConfig::FIELDS_LIBRARY, $this->getStringValue($data, 'liveFieldJsUrl')); - $configuration->set(SaferPayConfig::BUSINESS_LICENSE, !empty($data['liveBusinessLicense']) ? 1 : 0); - // Validate: business license requires field access token - $suffix = SaferPayConfig::getConfigSuffix(); - $haveFieldToken = $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . $suffix); - $haveBusinessLicense = $configuration->get(SaferPayConfig::BUSINESS_LICENSE . $suffix); - $businessLicenseDisabled = false; + // Auto-detect license features from Saferpay Management API + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + $licenseMessage = ''; + $hasBusinessLicense = false; - if (!$haveFieldToken && $haveBusinessLicense) { + if (!empty($activeUsername) && !empty($activePassword) && !empty($activeCustomerId)) { + try { + /** @var SaferPayGetLicense $getLicense */ + $getLicense = $this->module->getService(SaferPayGetLicense::class); + $licenseInfo = $getLicense->fetchLicenseWithCredentials( + $activeUsername, + $activePassword, + $activeCustomerId, + $isTestMode + ); + + $hasBusinessLicense = $licenseInfo['hasBusinessLicense']; + $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, $hasBusinessLicense ? 1 : 0); + } catch (\Exception $e) { + $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, 0); + $licenseMessage = ' ' . $this->module->l('Could not retrieve license information. Please verify your credentials.', self::FILE_NAME); + } + } else { $configuration->set(SaferPayConfig::BUSINESS_LICENSE . $suffix, 0); - $businessLicenseDisabled = true; - } - - if ($businessLicenseDisabled) { - $this->ajaxResponse( - true, - $this->module->l('Credentials saved. Field Access Token is required for business license — it has been disabled.', self::FILE_NAME) - ); - return; } - $this->ajaxResponse(true, $this->module->l('API Credentials saved successfully', self::FILE_NAME)); + $this->ajaxResponse( + true, + $this->module->l('API Credentials saved successfully', self::FILE_NAME) . $licenseMessage, + [ + 'hasBusinessLicense' => $hasBusinessLicense, + ] + ); } /** @@ -381,25 +393,21 @@ public function ajaxProcessSavePaymentMethods() public function ajaxProcessGetTerminals() { $data = $this->getJsonInput(); - if (!$data) { - $this->ajaxResponse(false, $this->module->l('Invalid request data', self::FILE_NAME)); - return; - } + $isTestMode = isset($data['env']) && $data['env'] === 'test'; + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; - $username = isset($data['username']) ? $data['username'] : ''; + $username = isset($data['username']) ? trim($data['username']) : ''; $password = isset($data['password']) ? $data['password'] : ''; $customerId = $this->parseCustomerIdFromUsername($username); - $isTestMode = isset($data['env']) && $data['env'] === 'test'; if ($password === self::PASSWORD_PLACEHOLDER) { - $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); $password = (string) $configuration->get(SaferPayConfig::PASSWORD . $suffix); } if (empty($username) || empty($password) || empty($customerId)) { - $this->ajaxResponse(false, $this->module->l('Username and password are required', self::FILE_NAME)); + $this->ajaxResponse(false, $this->module->l('Invalid credentials. Username format should be API_XXXXXX.', self::FILE_NAME)); return; } @@ -413,7 +421,7 @@ public function ajaxProcessGetTerminals() 'terminals' => $terminals, ]); } catch (\Exception $e) { - $this->ajaxResponse(false, $this->module->l('Failed to fetch terminals. Please check your credentials.', self::FILE_NAME)); + $this->ajaxResponse(false, $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME)); } } @@ -448,7 +456,6 @@ private function collectSettingsData() 'testMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'testFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX), 'testFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX), - 'testBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX), // Live credentials 'liveUsername' => (string) $configuration->get(SaferPayConfig::USERNAME), @@ -457,7 +464,10 @@ private function collectSettingsData() 'liveMerchantEmails' => (string) $configuration->get(SaferPayConfig::MERCHANT_EMAILS) ?: (string) \Configuration::get('PS_SHOP_EMAIL'), 'liveFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN), 'liveFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY), - 'liveBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), + + // License (auto-detected) + 'hasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix()), + 'licensePackage' => '', // Payment Processing 'paymentBehavior' => (int) $configuration->get(SaferPayConfig::PAYMENT_BEHAVIOR), @@ -621,12 +631,12 @@ private function getJsonInput() /** * Send AJAX JSON response */ - private function ajaxResponse($success, $message = '') + private function ajaxResponse($success, $message = '', $extraData = []) { - $this->sendJsonResponse([ + $this->sendJsonResponse(array_merge([ 'success' => $success, 'message' => $message, - ]); + ], $extraData)); } /** diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index b5589198..77d03e1b 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -120,16 +120,27 @@ private function getApiCredentialsTranslations() 'fetchTerminalsFromApi' => $this->module->l('Fetch terminals from API', self::FILE_NAME), 'merchantEmails' => $this->module->l('Merchant Emails', self::FILE_NAME), 'enterMerchantEmails' => $this->module->l('Enter merchant email addresses (comma-separated)', self::FILE_NAME), - 'separateEmails' => $this->module->l('Separate multiple email addresses with commas.', self::FILE_NAME), + 'separateEmails' => $this->module->l('These email addresses receive payment notification emails directly from SaferPay. Separate multiple email addresses with commas.', self::FILE_NAME), 'saferpayFields' => $this->module->l('Saferpay Fields', self::FILE_NAME), 'saferpayFieldsDescription' => $this->module->l('Configure Saferpay Fields for inline payment form integration.', self::FILE_NAME), 'fieldAccessTokenInfo' => $this->module->l('Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to', self::FILE_NAME), - 'fieldAccessTokenPath' => $this->module->l('Settings > Saferpay Fields Access Tokens', self::FILE_NAME), + 'fieldAccessTokenPath' => $this->module->l('Settings', self::FILE_NAME) . ' > ' . $this->module->l('Saferpay Fields Access Tokens', self::FILE_NAME), 'fieldAccessToken' => $this->module->l('Field Access Token', self::FILE_NAME), - 'enterFieldAccessToken' => $this->module->l('Enter field access token', self::FILE_NAME), + 'enterFieldAccessToken' => $this->module->l('Enter or generate token', self::FILE_NAME), + 'generate' => $this->module->l('Generate', self::FILE_NAME), + 'enterCredentialsToGenerateToken' => $this->module->l('Enter your API credentials first to generate a token.', self::FILE_NAME), + 'moreInformation' => $this->module->l('More information', self::FILE_NAME), 'fieldJsUrl' => $this->module->l('Field Javascript Library URL', self::FILE_NAME), - 'businessLicense' => $this->module->l('I have Business license', self::FILE_NAME), - 'businessLicenseDescription' => $this->module->l('Enable if you have a Saferpay Business license for advanced features.', self::FILE_NAME), + 'findLibraryUrlHere' => $this->module->l('Find the library URL here', self::FILE_NAME), + 'enterCredentialsFirst' => $this->module->l('Enter credentials first', self::FILE_NAME), + 'enterCredentialsToLoadTerminals' => $this->module->l('Enter your API username and password first to load available terminals.', self::FILE_NAME), + 'validatingCredentials' => $this->module->l('Validating credentials...', self::FILE_NAME), + 'credentialsValid' => $this->module->l('Credentials verified successfully.', self::FILE_NAME), + 'invalidCredentials' => $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME), + 'saferpayFieldsIncluded' => $this->module->l('Saferpay Fields is included in your license', self::FILE_NAME), + 'saferpayFieldsIncludedDescription' => $this->module->l('You can use hosted payment fields for a seamless checkout experience.', self::FILE_NAME), + 'saferpayFieldsNotIncluded' => $this->module->l('Saferpay Fields is not available', self::FILE_NAME), + 'saferpayFieldsNotIncludedDescription' => $this->module->l('Save valid API credentials to detect your license, or upgrade your Saferpay plan to access this feature.', self::FILE_NAME), ]; } diff --git a/views/js/admin/settings-app/src/api/client.ts b/views/js/admin/settings-app/src/api/client.ts index dac2fe45..1708bfe3 100644 --- a/views/js/admin/settings-app/src/api/client.ts +++ b/views/js/admin/settings-app/src/api/client.ts @@ -59,9 +59,10 @@ export async function getTerminals( env: string, username: string, password: string, -): Promise<{ success: boolean; terminals: TerminalOption[] }> { +): Promise<{ success: boolean; message?: string; terminals: TerminalOption[] }> { return postAjax('getTerminals', { env, username, password }) as Promise<{ success: boolean + message?: string terminals: TerminalOption[] }> } diff --git a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx index 2dccfa0e..2e1aa5ed 100644 --- a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -1,24 +1,29 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' -import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { AlertCircle, Eye, EyeOff, Key, Shield, Loader2, Info } from 'lucide-react' +import { AlertCircle, Eye, EyeOff, Key, Shield, Loader2, Info, CheckCircle2, Wand2, XCircle } from 'lucide-react' import { useSettings } from '@/context/settings-context' import { t } from '@/utils/translations' import type { TerminalOption } from '@/types' +type CredentialStatus = 'idle' | 'checking' | 'valid' | 'invalid' + export function ApiCredentials() { const { settings, updateSettings, saveCredentials, fetchTerminals, savingSections } = useSettings() const saving = savingSections.has('credentials') const [showApiPassword, setShowApiPassword] = useState(false) - const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) const [terminals, setTerminals] = useState([]) + const [credentialStatus, setCredentialStatus] = useState('idle') + const [credentialError, setCredentialError] = useState('') + const debounceTimer = useRef | null>(null) - const environment = settings.testMode ? 'test' : 'live' const isTest = settings.testMode + const prefix = isTest ? 'test' : 'live' + const envLabel = isTest ? t('test') : t('live') + const environment = isTest ? 'test' : 'live' const username = isTest ? settings.testUsername : settings.liveUsername const password = isTest ? settings.testPassword : settings.livePassword @@ -26,47 +31,42 @@ export function ApiCredentials() { const merchantEmails = isTest ? settings.testMerchantEmails : settings.liveMerchantEmails const fieldAccessToken = isTest ? settings.testFieldAccessToken : settings.liveFieldAccessToken const fieldJsUrl = isTest ? settings.testFieldJsUrl : settings.liveFieldJsUrl - const hasBusinessLicense = isTest ? settings.testBusinessLicense : settings.liveBusinessLicense - const prefix = isTest ? 'test' : 'live' + const hasCredentials = username.length > 0 && password.length > 0 + const setField = (field: string, value: string | boolean) => { updateSettings({ [`${prefix}${field.charAt(0).toUpperCase() + field.slice(1)}`]: value } as Record) } - const envLabel = isTest ? t('test') : t('live') - const hasCredentials = username.length > 0 && password.length > 0 + // Debounced credential check: when username+password are filled, validate and fetch terminals + useEffect(() => { + if (debounceTimer.current) clearTimeout(debounceTimer.current) - const handleFetchTerminals = useCallback(async () => { - if (!username || !password) return - setIsLoadingTerminals(true) - try { - const result = await fetchTerminals(environment, username, password) - setTerminals(result) - } finally { - setIsLoadingTerminals(false) + if (!hasCredentials) { + setCredentialStatus('idle') + setCredentialError('') + setTerminals([]) + return } - }, [environment, username, password, fetchTerminals]) - // Reset terminals when switching environment - useEffect(() => { - setTerminals([]) - }, [environment]) + debounceTimer.current = setTimeout(async () => { + setCredentialStatus('checking') + setCredentialError('') + try { + const result = await fetchTerminals(environment, username, password) + setTerminals(result) + setCredentialStatus('valid') + } catch { + setTerminals([]) + setCredentialStatus('invalid') + setCredentialError(t('invalidCredentials')) + } + }, 1000) - // Auto-fetch terminals when credentials are complete (debounced) - const fetchTimerRef = useRef | null>(null) - useEffect(() => { - if (fetchTimerRef.current) { - clearTimeout(fetchTimerRef.current) - } - if (hasCredentials && terminals.length === 0) { - fetchTimerRef.current = setTimeout(() => { - handleFetchTerminals() - }, 800) - } return () => { - if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + if (debounceTimer.current) clearTimeout(debounceTimer.current) } - }, [hasCredentials, environment]) // eslint-disable-line react-hooks/exhaustive-deps + }, [username, password, environment]) return (
@@ -76,13 +76,16 @@ export function ApiCredentials() {
{t('environment')} - - {t('envDescription')} - + {t('envDescription')}
setField('terminalId', val)} - > - - - - - {terminals.map((terminal) => ( - {terminal.name} - ))} - {terminalId && terminals.length === 0 && ( - {terminalId} - )} - - - -
-
+ {/* Credential status feedback */} + {credentialStatus === 'checking' && ( +
+ + {t('validatingCredentials')} +
+ )} + {credentialStatus === 'valid' && ( +
+ + {t('credentialsValid')} +
+ )} + {credentialStatus === 'invalid' && ( +
+ + {credentialError}
)} + {/* Terminal ID */} +
+ + + {!hasCredentials && ( +

+ {t('enterCredentialsToLoadTerminals')} +

+ )} +
+ + {/* Merchant Emails */}
{t('saferpayFields')} - - {t('saferpayFieldsDescription')} - + {t('saferpayFieldsDescription')}
+ {settings.hasBusinessLicense ? ( +
+ +
+

+ {t('saferpayFieldsIncluded')} +

+

+ {t('saferpayFieldsIncludedDescription')} +

+
+
+ ) : ( +
+ +
+

+ {t('saferpayFieldsNotIncluded')} +

+

+ {t('saferpayFieldsNotIncludedDescription')} +

+
+
+ )} +

{t('fieldAccessTokenInfo')}{' '} - {t('fieldAccessTokenPath')}. + {t('fieldAccessTokenPath')}.{' '} + {t('moreInformation')}

- setField('fieldAccessToken', e.target.value)} - /> +
+ setField('fieldAccessToken', e.target.value)} + disabled={!settings.hasBusinessLicense} + className="sp-flex-1" + /> + +
+

+ {t('enterCredentialsToGenerateToken')} +

@@ -269,25 +317,13 @@ export function ApiCredentials() { placeholder="https://www.saferpay.com/Fields/lib/1/" value={fieldJsUrl} onChange={(e) => setField('fieldJsUrl', e.target.value)} + disabled={!settings.hasBusinessLicense} /> + + {t('findLibraryUrlHere')} +
- -
-
- -

- {t('businessLicenseDescription')} -

-
- setField('businessLicense', checked)} - /> -
diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 57e9af8b..3dfd23ff 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -25,10 +25,9 @@ const SettingsContext = createContext(null) export function SettingsProvider({ children }: { children: React.ReactNode }) { const [settings, setSettings] = useState(() => window.saferpaySettingsData) - const [paymentMethods, setPaymentMethods] = useState(() => { - const methods = window.saferpaySettingsData.paymentMethods - return Array.isArray(methods) ? methods : [] - }) + const [paymentMethods, setPaymentMethods] = useState( + () => Array.isArray(window.saferpaySettingsData.paymentMethods) ? window.saferpaySettingsData.paymentMethods : [], + ) const [savingSections, setSavingSections] = useState>(new Set()) const settingsRef = useRef(settings) @@ -73,59 +72,64 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { }, []) const saveCredentials = useCallback(async () => { - const currentSettings = settingsRef.current - await handleSave(() => api.saveCredentials({ - testMode: currentSettings.testMode, - testUsername: currentSettings.testUsername, - testPassword: currentSettings.testPassword, - testTerminalId: currentSettings.testTerminalId, - testMerchantEmails: currentSettings.testMerchantEmails, - testFieldAccessToken: currentSettings.testFieldAccessToken, - testFieldJsUrl: currentSettings.testFieldJsUrl, - testBusinessLicense: currentSettings.testBusinessLicense, - liveUsername: currentSettings.liveUsername, - livePassword: currentSettings.livePassword, - liveTerminalId: currentSettings.liveTerminalId, - liveMerchantEmails: currentSettings.liveMerchantEmails, - liveFieldAccessToken: currentSettings.liveFieldAccessToken, - liveFieldJsUrl: currentSettings.liveFieldJsUrl, - liveBusinessLicense: currentSettings.liveBusinessLicense, - }), 'API Credentials', 'credentials') + const s = settingsRef.current + await handleSave(async () => { + const result = await api.saveCredentials({ + testMode: s.testMode, + testUsername: s.testUsername, + testPassword: s.testPassword, + testTerminalId: s.testTerminalId, + testMerchantEmails: s.testMerchantEmails, + testFieldAccessToken: s.testFieldAccessToken, + testFieldJsUrl: s.testFieldJsUrl, + liveUsername: s.liveUsername, + livePassword: s.livePassword, + liveTerminalId: s.liveTerminalId, + liveMerchantEmails: s.liveMerchantEmails, + liveFieldAccessToken: s.liveFieldAccessToken, + liveFieldJsUrl: s.liveFieldJsUrl, + }) + const data = result as unknown as Record + if (result.success && typeof data.hasBusinessLicense === 'boolean') { + setSettings((prev) => ({ ...prev, hasBusinessLicense: data.hasBusinessLicense as boolean })) + } + return result + }, 'API Credentials', 'credentials') }, [handleSave]) - const savePaymentProcessingFn = useCallback(async () => { - const currentSettings = settingsRef.current + const savePaymentProcessing = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.savePaymentProcessing({ - paymentBehavior: currentSettings.paymentBehavior, - paymentBehaviorWithout3D: currentSettings.paymentBehaviorWithout3D, - restrictRefund: currentSettings.restrictRefund, - orderCreationAfterAuth: currentSettings.orderCreationAfterAuth, - groupCards: currentSettings.groupCards, - groupCardsLogo: currentSettings.groupCardsLogo, - creditCardSave: currentSettings.creditCardSave, + paymentBehavior: s.paymentBehavior, + paymentBehaviorWithout3D: s.paymentBehaviorWithout3D, + restrictRefund: s.restrictRefund, + orderCreationAfterAuth: s.orderCreationAfterAuth, + groupCards: s.groupCards, + groupCardsLogo: s.groupCardsLogo, + creditCardSave: s.creditCardSave, }), 'Payment Processing', 'paymentProcessing') }, [handleSave]) - const saveEmailSettingsFn = useCallback(async () => { - const currentSettings = settingsRef.current + const saveEmailSettings = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.saveEmailSettings({ - allowSaferpayMail: currentSettings.allowSaferpayMail, - sendNewOrderMail: currentSettings.sendNewOrderMail, - sendOrderConfMail: currentSettings.sendOrderConfMail, + allowSaferpayMail: s.allowSaferpayMail, + sendNewOrderMail: s.sendNewOrderMail, + sendOrderConfMail: s.sendOrderConfMail, }), 'Email Settings', 'emailSettings') }, [handleSave]) - const saveGeneralSettingsFn = useCallback(async () => { - const currentSettings = settingsRef.current + const saveGeneralSettings = useCallback(async () => { + const s = settingsRef.current await handleSave(() => api.saveGeneralSettings({ - orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, - paymentDescription: currentSettings.paymentDescription, - configurationName: currentSettings.configurationName, - debugMode: currentSettings.debugMode, + orderStateAwaitingPayment: s.orderStateAwaitingPayment, + paymentDescription: s.paymentDescription, + configurationName: s.configurationName, + debugMode: s.debugMode, }), 'General Settings', 'generalSettings') }, [handleSave]) - const savePaymentMethodsFn = useCallback(async () => { + const savePaymentMethods = useCallback(async () => { await handleSave( () => api.savePaymentMethods(paymentMethodsRef.current), 'Payment Methods', @@ -136,40 +140,30 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { const refreshPaymentMethods = useCallback(async () => { try { const result = await api.refreshData() - if (result.success && result.data?.paymentMethods) { - const methods = result.data.paymentMethods - if (Array.isArray(methods)) { - setPaymentMethods(methods as PaymentMethodData[]) - } + if (result.success && Array.isArray(result.data?.paymentMethods)) { + setPaymentMethods(result.data.paymentMethods as PaymentMethodData[]) } - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - toast({ title: t('errorRefreshingPaymentMethods', message), variant: 'destructive' }) + } catch { + toast({ title: t('errorRefreshingPaymentMethods'), variant: 'destructive' }) } }, []) const fetchTerminals = useCallback(async (env: string, username: string, password: string) => { - try { - const result = await api.getTerminals(env, username, password) - if (result.success) { - return result.terminals - } - toast({ title: t('failedToFetchTerminals'), variant: 'destructive' }) - return [] - } catch { - toast({ title: t('errorFetchingTerminals'), variant: 'destructive' }) - return [] + const result = await api.getTerminals(env, username, password) + if (!result.success) { + throw new Error(result.message || t('failedToFetchTerminals')) } + return result.terminals }, []) const value = useMemo(() => ({ settings, updateSettings, saveCredentials, - savePaymentProcessing: savePaymentProcessingFn, - saveEmailSettings: saveEmailSettingsFn, - saveGeneralSettings: saveGeneralSettingsFn, - savePaymentMethods: savePaymentMethodsFn, + savePaymentProcessing, + saveEmailSettings, + saveGeneralSettings, + savePaymentMethods, fetchTerminals, refreshPaymentMethods, paymentMethods, @@ -179,10 +173,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { settings, updateSettings, saveCredentials, - savePaymentProcessingFn, - saveEmailSettingsFn, - saveGeneralSettingsFn, - savePaymentMethodsFn, + savePaymentProcessing, + saveEmailSettings, + saveGeneralSettings, + savePaymentMethods, fetchTerminals, refreshPaymentMethods, paymentMethods, From 6185eb8a0dfc07943300871d03ac841504b80ff0 Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Sun, 15 Mar 2026 11:57:29 +0200 Subject: [PATCH 04/30] pr fix --- ...dminSaferPayOfficialSettingsController.php | 3 +- .../src/context/settings-context.tsx | 62 +++++++++---------- .../js/admin/settings-app/src/types/index.ts | 6 +- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index bccaea21..dfce1b08 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -407,7 +407,7 @@ public function ajaxProcessGetTerminals() } if (empty($username) || empty($password) || empty($customerId)) { - $this->ajaxResponse(false, $this->module->l('Invalid credentials. Username format should be API_XXXXXX.', self::FILE_NAME)); + $this->ajaxResponse(false, $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME)); return; } @@ -467,7 +467,6 @@ private function collectSettingsData() // License (auto-detected) 'hasBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix()), - 'licensePackage' => '', // Payment Processing 'paymentBehavior' => (int) $configuration->get(SaferPayConfig::PAYMENT_BEHAVIOR), diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 3dfd23ff..e71802dc 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -72,22 +72,22 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { }, []) const saveCredentials = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(async () => { const result = await api.saveCredentials({ - testMode: s.testMode, - testUsername: s.testUsername, - testPassword: s.testPassword, - testTerminalId: s.testTerminalId, - testMerchantEmails: s.testMerchantEmails, - testFieldAccessToken: s.testFieldAccessToken, - testFieldJsUrl: s.testFieldJsUrl, - liveUsername: s.liveUsername, - livePassword: s.livePassword, - liveTerminalId: s.liveTerminalId, - liveMerchantEmails: s.liveMerchantEmails, - liveFieldAccessToken: s.liveFieldAccessToken, - liveFieldJsUrl: s.liveFieldJsUrl, + testMode: currentSettings.testMode, + testUsername: currentSettings.testUsername, + testPassword: currentSettings.testPassword, + testTerminalId: currentSettings.testTerminalId, + testMerchantEmails: currentSettings.testMerchantEmails, + testFieldAccessToken: currentSettings.testFieldAccessToken, + testFieldJsUrl: currentSettings.testFieldJsUrl, + liveUsername: currentSettings.liveUsername, + livePassword: currentSettings.livePassword, + liveTerminalId: currentSettings.liveTerminalId, + liveMerchantEmails: currentSettings.liveMerchantEmails, + liveFieldAccessToken: currentSettings.liveFieldAccessToken, + liveFieldJsUrl: currentSettings.liveFieldJsUrl, }) const data = result as unknown as Record if (result.success && typeof data.hasBusinessLicense === 'boolean') { @@ -98,34 +98,34 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { }, [handleSave]) const savePaymentProcessing = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.savePaymentProcessing({ - paymentBehavior: s.paymentBehavior, - paymentBehaviorWithout3D: s.paymentBehaviorWithout3D, - restrictRefund: s.restrictRefund, - orderCreationAfterAuth: s.orderCreationAfterAuth, - groupCards: s.groupCards, - groupCardsLogo: s.groupCardsLogo, - creditCardSave: s.creditCardSave, + paymentBehavior: currentSettings.paymentBehavior, + paymentBehaviorWithout3D: currentSettings.paymentBehaviorWithout3D, + restrictRefund: currentSettings.restrictRefund, + orderCreationAfterAuth: currentSettings.orderCreationAfterAuth, + groupCards: currentSettings.groupCards, + groupCardsLogo: currentSettings.groupCardsLogo, + creditCardSave: currentSettings.creditCardSave, }), 'Payment Processing', 'paymentProcessing') }, [handleSave]) const saveEmailSettings = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.saveEmailSettings({ - allowSaferpayMail: s.allowSaferpayMail, - sendNewOrderMail: s.sendNewOrderMail, - sendOrderConfMail: s.sendOrderConfMail, + allowSaferpayMail: currentSettings.allowSaferpayMail, + sendNewOrderMail: currentSettings.sendNewOrderMail, + sendOrderConfMail: currentSettings.sendOrderConfMail, }), 'Email Settings', 'emailSettings') }, [handleSave]) const saveGeneralSettings = useCallback(async () => { - const s = settingsRef.current + const currentSettings = settingsRef.current await handleSave(() => api.saveGeneralSettings({ - orderStateAwaitingPayment: s.orderStateAwaitingPayment, - paymentDescription: s.paymentDescription, - configurationName: s.configurationName, - debugMode: s.debugMode, + orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, + paymentDescription: currentSettings.paymentDescription, + configurationName: currentSettings.configurationName, + debugMode: currentSettings.debugMode, }), 'General Settings', 'generalSettings') }, [handleSave]) diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index ad8d82aa..a8c88e05 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -25,8 +25,6 @@ export interface SaferpaySettingsData { testMerchantEmails: string testFieldAccessToken: string testFieldJsUrl: string - testBusinessLicense: boolean - // Live credentials liveUsername: string livePassword: string @@ -34,7 +32,9 @@ export interface SaferpaySettingsData { liveMerchantEmails: string liveFieldAccessToken: string liveFieldJsUrl: string - liveBusinessLicense: boolean + + // License (read-only, auto-detected from API) + hasBusinessLicense: boolean // Payment Processing paymentBehavior: number From 38f906b003a3881798ca1e457c3ba05cc7cab3a4 Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Mon, 16 Mar 2026 11:41:26 +0200 Subject: [PATCH 05/30] fields settings licence block logic and licence fetching --- ...dminSaferPayOfficialSettingsController.php | 28 ++++- src/Api/Request/GetLicenseService.php | 76 +++++++++++++ .../Request/GetLicense/GetLicenseRequest.php | 62 ++++++++++ src/Service/SaferPayGetLicense.php | 106 ++++++++++++++++++ src/Service/SettingsTranslationService.php | 2 - .../components/settings/api-credentials.tsx | 44 +++----- 6 files changed, 285 insertions(+), 33 deletions(-) create mode 100644 src/Api/Request/GetLicenseService.php create mode 100644 src/DTO/Request/GetLicense/GetLicenseRequest.php create mode 100644 src/Service/SaferPayGetLicense.php diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index dfce1b08..4e3fa993 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -233,7 +233,7 @@ public function ajaxProcessSaveCredentials() $this->ajaxResponse( true, - $this->module->l('API Credentials saved successfully', self::FILE_NAME) . $licenseMessage, + $this->module->l('Settings saved successfully.', self::FILE_NAME) . $licenseMessage, [ 'hasBusinessLicense' => $hasBusinessLicense, ] @@ -445,6 +445,32 @@ private function collectSettingsData() /** @var SaferPayConfiguration $configuration */ $configuration = $this->module->getService(SaferPayConfiguration::class); + // Re-fetch license from Saferpay Management API on every page load + $isTestMode = (bool) $configuration->get(SaferPayConfig::TEST_MODE); + $suffix = $isTestMode ? SaferPayConfig::TEST_SUFFIX : ''; + $activeUsername = (string) $configuration->get(SaferPayConfig::USERNAME . $suffix); + $activePassword = (string) $configuration->get(SaferPayConfig::PASSWORD . $suffix); + $activeCustomerId = (string) $configuration->get(SaferPayConfig::CUSTOMER_ID . $suffix); + + if (!empty($activeUsername) && !empty($activePassword) && !empty($activeCustomerId)) { + try { + /** @var SaferPayGetLicense $getLicense */ + $getLicense = $this->module->getService(SaferPayGetLicense::class); + $licenseInfo = $getLicense->fetchLicenseWithCredentials( + $activeUsername, + $activePassword, + $activeCustomerId, + $isTestMode + ); + $configuration->set( + SaferPayConfig::BUSINESS_LICENSE . $suffix, + $licenseInfo['hasBusinessLicense'] ? 1 : 0 + ); + } catch (\Exception $e) { + // Silently fall back to stored value + } + } + $data = [ // Environment 'testMode' => (bool) $configuration->get(SaferPayConfig::TEST_MODE), diff --git a/src/Api/Request/GetLicenseService.php b/src/Api/Request/GetLicenseService.php new file mode 100644 index 00000000..0299875f --- /dev/null +++ b/src/Api/Request/GetLicenseService.php @@ -0,0 +1,76 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Api\Request; + +use Invertus\SaferPay\Api\ApiRequest; +use Invertus\SaferPay\DTO\Request\GetLicense\GetLicenseRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GetLicenseService +{ + /** @var ApiRequest */ + private $apiRequest; + + public function __construct(ApiRequest $apiRequest) + { + $this->apiRequest = $apiRequest; + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @return mixed + */ + public function getLicense(GetLicenseRequest $request, $username, $password, $baseUrl) + { + return $this->apiRequest->getWithCredentials( + $request->generateRequestUrl(), + $username, + $password, + $baseUrl + ); + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @return mixed + */ + public function getLicenseFallback(GetLicenseRequest $request, $username, $password, $baseUrl) + { + return $this->apiRequest->getWithCredentials( + $request->generateFallbackRequestUrl(), + $username, + $password, + $baseUrl + ); + } +} diff --git a/src/DTO/Request/GetLicense/GetLicenseRequest.php b/src/DTO/Request/GetLicense/GetLicenseRequest.php new file mode 100644 index 00000000..0cf13981 --- /dev/null +++ b/src/DTO/Request/GetLicense/GetLicenseRequest.php @@ -0,0 +1,62 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\DTO\Request\GetLicense; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GetLicenseRequest +{ + /** @var string */ + private $customerId; + + /** + * @param string $customerId + */ + public function __construct($customerId) + { + if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $customerId)) { + throw new \InvalidArgumentException('Invalid customer ID format'); + } + + $this->customerId = $customerId; + } + + /** + * @return string + */ + public function generateRequestUrl() + { + return sprintf('rest/customers/%s/license', $this->customerId); + } + + /** + * @return string + */ + public function generateFallbackRequestUrl() + { + return sprintf('rest/customers/%s/license-configuration', $this->customerId); + } +} diff --git a/src/Service/SaferPayGetLicense.php b/src/Service/SaferPayGetLicense.php new file mode 100644 index 00000000..a88e410a --- /dev/null +++ b/src/Service/SaferPayGetLicense.php @@ -0,0 +1,106 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Exception; +use Invertus\SaferPay\Api\Request\GetLicenseService; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\DTO\Request\GetLicense\GetLicenseRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SaferPayGetLicense +{ + const FEATURE_HOSTED_ENTRY_FORM = 'HOSTED_ENTRY_FORM'; + + /** @var GetLicenseService */ + private $getLicenseService; + + public function __construct(GetLicenseService $getLicenseService) + { + $this->getLicenseService = $getLicenseService; + } + + /** + * @param string $username + * @param string $password + * @param string $customerId + * @param bool $isTestMode + * + * @return array{hasBusinessLicense: bool, packageName: string, features: array} + * + * @throws Exception + */ + public function fetchLicenseWithCredentials($username, $password, $customerId, $isTestMode) + { + $baseUrl = $isTestMode ? SaferPayConfig::TEST_API : SaferPayConfig::API; + $request = new GetLicenseRequest($customerId); + + $response = $this->fetchWithFallback($request, $username, $password, $baseUrl); + + $packageName = ''; + if (isset($response->Package->DisplayName)) { + $packageName = $response->Package->DisplayName; + } + + $features = []; + $featureList = isset($response->Features) ? $response->Features : []; + if (is_array($featureList)) { + foreach ($featureList as $feature) { + if (isset($feature->Id)) { + $features[] = $feature->Id; + } + } + } + + $hasBusinessLicense = in_array(self::FEATURE_HOSTED_ENTRY_FORM, $features, true); + + return [ + 'hasBusinessLicense' => $hasBusinessLicense, + 'packageName' => $packageName, + 'features' => $features, + ]; + } + + /** + * @param GetLicenseRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * + * @return mixed + * + * @throws Exception + */ + private function fetchWithFallback(GetLicenseRequest $request, $username, $password, $baseUrl) + { + try { + return $this->getLicenseService->getLicense($request, $username, $password, $baseUrl); + } catch (Exception $e) { + return $this->getLicenseService->getLicenseFallback($request, $username, $password, $baseUrl); + } + } +} diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 77d03e1b..fcb534a1 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -139,8 +139,6 @@ private function getApiCredentialsTranslations() 'invalidCredentials' => $this->module->l('Invalid credentials. Please check your username and password.', self::FILE_NAME), 'saferpayFieldsIncluded' => $this->module->l('Saferpay Fields is included in your license', self::FILE_NAME), 'saferpayFieldsIncludedDescription' => $this->module->l('You can use hosted payment fields for a seamless checkout experience.', self::FILE_NAME), - 'saferpayFieldsNotIncluded' => $this->module->l('Saferpay Fields is not available', self::FILE_NAME), - 'saferpayFieldsNotIncludedDescription' => $this->module->l('Save valid API credentials to detect your license, or upgrade your Saferpay plan to access this feature.', self::FILE_NAME), ]; } diff --git a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx index 2e1aa5ed..663b72d0 100644 --- a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -240,39 +240,25 @@ export function ApiCredentials() { - {/* Saferpay Fields Configuration */} - + {/* Saferpay Fields Configuration - only shown when business license detected */} + {settings.hasBusinessLicense && {t('saferpayFields')} {t('saferpayFieldsDescription')}
- {settings.hasBusinessLicense ? ( -
- -
-

- {t('saferpayFieldsIncluded')} -

-

- {t('saferpayFieldsIncludedDescription')} -

-
-
- ) : ( -
- -
-

- {t('saferpayFieldsNotIncluded')} -

-

- {t('saferpayFieldsNotIncludedDescription')} -

-
+
+ +
+

+ {t('saferpayFieldsIncluded')} +

+

+ {t('saferpayFieldsIncludedDescription')} +

- )} +
@@ -293,12 +279,11 @@ export function ApiCredentials() { placeholder={t('enterFieldAccessToken')} value={fieldAccessToken} onChange={(e) => setField('fieldAccessToken', e.target.value)} - disabled={!settings.hasBusinessLicense} className="sp-flex-1" />
- + }
diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index e71802dc..6abb19d3 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -15,6 +15,7 @@ interface SettingsContextValue { saveGeneralSettings: () => Promise savePaymentMethods: () => Promise fetchTerminals: (env: string, username: string, password: string) => Promise + generateFieldAccessToken: () => Promise<{ success: boolean; message?: string; token?: string }> refreshPaymentMethods: () => Promise paymentMethods: PaymentMethodData[] updatePaymentMethod: (name: string, updates: Partial) => void @@ -148,6 +149,26 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { } }, []) + const generateFieldAccessToken = useCallback(async () => { + const s = settingsRef.current + const env = s.testMode ? 'test' : 'live' + const username = s.testMode ? s.testUsername : s.liveUsername + const password = s.testMode ? s.testPassword : s.livePassword + const terminalId = s.testMode ? s.testTerminalId : s.liveTerminalId + + const result = await api.generateFieldAccessToken(env, username, password, terminalId) + if (!result.success) { + throw new Error(result.message || t('failedToGenerateToken')) + } + + if (result.token) { + const fieldKey = s.testMode ? 'testFieldAccessToken' : 'liveFieldAccessToken' + setSettings((prev) => ({ ...prev, [fieldKey]: result.token })) + } + + return result + }, []) + const fetchTerminals = useCallback(async (env: string, username: string, password: string) => { const result = await api.getTerminals(env, username, password) if (!result.success) { @@ -165,6 +186,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { saveGeneralSettings, savePaymentMethods, fetchTerminals, + generateFieldAccessToken, refreshPaymentMethods, paymentMethods, updatePaymentMethod, @@ -178,6 +200,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { saveGeneralSettings, savePaymentMethods, fetchTerminals, + generateFieldAccessToken, refreshPaymentMethods, paymentMethods, updatePaymentMethod, From b5adaaa8f4cf1d7265058eb9ceebef1d4370e2ed Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Mon, 16 Mar 2026 17:10:38 +0200 Subject: [PATCH 07/30] feat: add Capture option to 3D Secure failure behavior setting --- controllers/front/notify.php | 41 +++++++++++-------- controllers/front/return.php | 20 +++++++-- saferpayofficial.php | 2 +- src/Config/SaferPayConfig.php | 3 +- src/Service/SettingsTranslationService.php | 3 +- upgrade/install-2.0.3.php | 36 ++++++++++++++++ .../settings/payment-processing.tsx | 14 +++++++ 7 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 upgrade/install-2.0.3.php diff --git a/controllers/front/notify.php b/controllers/front/notify.php index 870464e7..35bb7d25 100755 --- a/controllers/front/notify.php +++ b/controllers/front/notify.php @@ -140,27 +140,36 @@ public function postProcess() //NOTE must be left below assert action to get newest information. $order = new Order($orderId); + $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) && - (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D) === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL + in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); - $orderStatusService->cancel($order); - - $logger->debug(sprintf('%s - Liability shift is false', self::FILE_NAME), [ - 'context' => [ - 'id_order' => $order->id, - ], - ]); - $logger->debug(sprintf('%s - liability shift is false', self::FILE_NAME), [ - 'context' => [ - 'id_order' => $order->id, - ], - ]); - - die($this->module->l('Liability shift is false', self::FILE_NAME)); + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL) { + $orderStatusService->cancel($order); + + $logger->debug(sprintf('%s - Liability shift is false, canceling order', self::FILE_NAME), [ + 'context' => [ + 'id_order' => $order->id, + ], + ]); + + die($this->module->l('Liability shift is false', self::FILE_NAME)); + } elseif ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE + && SaferPayConfig::supportsOrderCapture($order->payment) + && $transactionStatus !== TransactionStatus::CAPTURED + ) { + $orderStatusService->capture($order); + + $logger->debug(sprintf('%s - Liability shift is false, capturing order', self::FILE_NAME), [ + 'context' => [ + 'id_order' => $order->id, + ], + ]); + } } //NOTE to get latest information possible and not override new information. diff --git a/controllers/front/return.php b/controllers/front/return.php index b5fc37c6..f36c7fe2 100755 --- a/controllers/front/return.php +++ b/controllers/front/return.php @@ -329,19 +329,31 @@ private function createAndValidateOrder($assertResponseBody, $transactionStatus, $orderId = Order::getIdByCartId($cartId); $order = new Order($orderId); + $paymentBehaviorWithout3D = (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D); + if (!$assertResponseBody->getLiability()->getLiabilityShift() && - in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) && - (int) Configuration::get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D) === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL + in_array($order->payment, SaferPayConfig::SUPPORTED_3DS_PAYMENT_METHODS) ) { /** @var SaferPayOrderStatusService $orderStatusService */ $orderStatusService = $this->module->getService(SaferPayOrderStatusService::class); - $orderStatusService->cancel($order); + + if ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL) { + $orderStatusService->cancel($order); + } elseif ($paymentBehaviorWithout3D === SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE + && SaferPayConfig::supportsOrderCapture($order->payment) + && $transactionStatus !== TransactionStatus::CAPTURED + ) { + $orderStatusService->capture($order); + + return; + } } //NOTE to get latest information possible and not override new information. - $paymentMethod = $assertResponseBody->getPaymentMeans()->getBrand()->getPaymentMethod();// if payment does not support order capture, it means it always auto-captures it (at least with accountToAccount payment), + $paymentMethod = $assertResponseBody->getPaymentMeans()->getBrand()->getPaymentMethod(); + // if payment does not support order capture, it means it always auto-captures it (at least with accountToAccount payment), // so in this case if status comes back "captured" we just update the order state accordingly if (!SaferPayConfig::supportsOrderCapture($paymentMethod) && $transactionStatus === TransactionStatus::CAPTURED diff --git a/saferpayofficial.php b/saferpayofficial.php index b0acd463..75e49ed5 100755 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -65,7 +65,7 @@ public function __construct($name = null) { $this->name = 'saferpayofficial'; $this->author = 'Invertus'; - $this->version = '2.0.2'; + $this->version = '2.0.3'; $this->module_key = '3d3506c3e184a1fe63b936b82bda1bdf'; $this->displayName = 'SaferpayOfficial'; $this->description = 'Saferpay Payment module'; diff --git a/src/Config/SaferPayConfig.php b/src/Config/SaferPayConfig.php index ae787999..b7168ab4 100755 --- a/src/Config/SaferPayConfig.php +++ b/src/Config/SaferPayConfig.php @@ -276,6 +276,7 @@ class SaferPayConfig const PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL = 0; const PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE = 1; + const PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE = 2; const SAFERPAY_CARDFORM_HOLDERNAME_REQUIRENCE = 'MANDATORY'; const SAFERPAY_DEBUG_MODE = 'SAFERPAY_DEBUG_MODE'; @@ -433,7 +434,7 @@ public static function getDefaultConfiguration() RequestHeader::SPEC_REFUND_VERSION => SaferPayConfig::API_VERSION, RequestHeader::RETRY_INDICATOR => 0, SaferPayConfig::PAYMENT_BEHAVIOR => 1, - SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D => 1, + SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D => 0, SaferPayConfig::SAFERPAY_ALLOW_SAFERPAY_SEND_CUSTOMER_MAIL => 1, SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION => self::SAFERPAY_PAYMENT_DESCRIPTION_DEFAULT_VALUE, SaferPayConfig::FIELDS_LIBRARY => self::FIELDS_LIBRARY_DEFAULT_VALUE, diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 03d1545f..40959186 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -174,11 +174,12 @@ private function getPaymentProcessingTranslations() 'chargeImmediately' => $this->module->l('Charge immediately', self::FILE_NAME), 'authorize' => $this->module->l('Authorize', self::FILE_NAME), 'reserveAndCaptureLater' => $this->module->l('Reserve and capture later', self::FILE_NAME), - 'behaviourWhen3dsFails' => $this->module->l('Behaviour when 3D Secure fails', self::FILE_NAME), + 'behaviourWhen3dsFails' => $this->module->l('Behavior when 3D Secure Payer Authentication was not successful and liability shift was not granted', self::FILE_NAME), 'behaviourWhen3dsDescription' => $this->module->l('Default payment behavior for payment without 3-D Secure.', self::FILE_NAME), 'cancel' => $this->module->l('Cancel', self::FILE_NAME), 'rejectPayment' => $this->module->l('Reject the payment', self::FILE_NAME), 'continueWithout3ds' => $this->module->l('Continue without 3DS', self::FILE_NAME), + 'captureWithout3ds' => $this->module->l('Charge immediately', self::FILE_NAME), 'restrictRefundAmount' => $this->module->l('Restrict RefundAmount to Captured Amount', self::FILE_NAME), 'restrictRefundDescription' => $this->module->l('If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value.', self::FILE_NAME), 'orderCreationRule' => $this->module->l('Order creation rule', self::FILE_NAME), diff --git a/upgrade/install-2.0.3.php b/upgrade/install-2.0.3.php new file mode 100644 index 00000000..c221693b --- /dev/null +++ b/upgrade/install-2.0.3.php @@ -0,0 +1,36 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * Upgrade to 2.0.3: + * - Add "Capture" option for "Behavior without 3D Secure" setting. + * - Existing installs keep their current value (no overwrite). + */ +function upgrade_module_2_0_3() +{ + return true; +} diff --git a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx index a24ad5ba..c19e98a4 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx @@ -113,6 +113,20 @@ export function PaymentProcessing() { {t('continueWithout3ds')}
+
From 6b92b9f7b4fdd79a151920600d3568406896265c Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Mon, 16 Mar 2026 17:15:10 +0200 Subject: [PATCH 08/30] remove upgrade --- saferpayofficial.php | 2 +- upgrade/install-2.0.3.php | 36 ------------------------------------ 2 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 upgrade/install-2.0.3.php diff --git a/saferpayofficial.php b/saferpayofficial.php index 75e49ed5..b0acd463 100755 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -65,7 +65,7 @@ public function __construct($name = null) { $this->name = 'saferpayofficial'; $this->author = 'Invertus'; - $this->version = '2.0.3'; + $this->version = '2.0.2'; $this->module_key = '3d3506c3e184a1fe63b936b82bda1bdf'; $this->displayName = 'SaferpayOfficial'; $this->description = 'Saferpay Payment module'; diff --git a/upgrade/install-2.0.3.php b/upgrade/install-2.0.3.php deleted file mode 100644 index c221693b..00000000 --- a/upgrade/install-2.0.3.php +++ /dev/null @@ -1,36 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ - -if (!defined('_PS_VERSION_')) { - exit; -} - -/** - * Upgrade to 2.0.3: - * - Add "Capture" option for "Behavior without 3D Secure" setting. - * - Existing installs keep their current value (no overwrite). - */ -function upgrade_module_2_0_3() -{ - return true; -} From 4e7eb47121cece54252e29ad8d866f30109c625f Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Tue, 17 Mar 2026 10:23:51 +0200 Subject: [PATCH 09/30] feat: add Order reference on payment page toggle to control Description field value --- ...dminSaferPayOfficialSettingsController.php | 2 + src/Config/SaferPayConfig.php | 4 ++ src/Service/Request/RequestObjectCreator.php | 11 ++- src/Service/SettingsTranslationService.php | 4 ++ .../components/settings/general-settings.tsx | 68 +++++++++++++++---- .../src/context/settings-context.tsx | 1 + .../js/admin/settings-app/src/types/index.ts | 1 + 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index bf6d10fe..70b8e14a 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -313,6 +313,7 @@ public function ajaxProcessSaveGeneralSettings() $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, $this->getIntValue($data, 'orderStateAwaitingPayment')); $configuration->set(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION, $this->getStringValue($data, 'paymentDescription')); $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $this->getStringValue($data, 'configurationName')); + $configuration->set(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION, $this->getIntValue($data, 'orderIdOption')); $configuration->set(SaferPayConfig::SAFERPAY_DEBUG_MODE, !empty($data['debugMode']) ? 1 : 0); $this->ajaxResponse(true, $this->module->l('General settings saved successfully', self::FILE_NAME)); @@ -543,6 +544,7 @@ private function collectSettingsData() 'orderStateAwaitingPayment' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT), 'paymentDescription' => (string) $configuration->get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION), 'configurationName' => (string) $configuration->get(SaferPayConfig::CONFIGURATION_NAME), + 'orderIdOption' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION), 'debugMode' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_DEBUG_MODE), // Reference data diff --git a/src/Config/SaferPayConfig.php b/src/Config/SaferPayConfig.php index b7168ab4..793d0b8e 100755 --- a/src/Config/SaferPayConfig.php +++ b/src/Config/SaferPayConfig.php @@ -278,6 +278,8 @@ class SaferPayConfig const PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE = 1; const PAYMENT_BEHAVIOR_WITHOUT_3D_CAPTURE = 2; + const SAFERPAY_ORDER_ID_OPTION = 'SAFERPAY_ORDER_ID_OPTION'; + const SAFERPAY_CARDFORM_HOLDERNAME_REQUIRENCE = 'MANDATORY'; const SAFERPAY_DEBUG_MODE = 'SAFERPAY_DEBUG_MODE'; @@ -440,6 +442,7 @@ public static function getDefaultConfiguration() SaferPayConfig::FIELDS_LIBRARY => self::FIELDS_LIBRARY_DEFAULT_VALUE, SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX => self::FIELDS_LIBRARY_TEST_DEFAULT_VALUE, self::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION => 0, + self::SAFERPAY_ORDER_ID_OPTION => 0, self::TEST_MODE => 1, self::HOSTED_FIELDS_TEMPLATE => self::HOSTED_FIELDS_TEMPLATE_DEFAULT, self::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT => (int) Configuration::get( @@ -480,6 +483,7 @@ public static function getUninstallConfiguration() self::FIELDS_LIBRARY, self::FIELDS_LIBRARY . self::TEST_SUFFIX, self::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION, + self::SAFERPAY_ORDER_ID_OPTION, self::SAFERPAY_SEND_ORDER_CONF_MAIL, self::SAFERPAY_GROUP_CARDS, ]; diff --git a/src/Service/Request/RequestObjectCreator.php b/src/Service/Request/RequestObjectCreator.php index 6e961489..a5c40a19 100755 --- a/src/Service/Request/RequestObjectCreator.php +++ b/src/Service/Request/RequestObjectCreator.php @@ -118,13 +118,20 @@ public function createPayment(Cart $cart, $totalPrice) $payment = new Payment(); $payment->setValue($totalPrice); $payment->setCurrencyCode($currency['iso_code']); - $payment->setDescription((string) Configuration::get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION)); + + $description = (string) Configuration::get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION); + $orderIdOption = (int) Configuration::get(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION); + + if ($orderIdOption === 0 && !empty($order)) { + $payment->setDescription($order->reference); + } else { + $payment->setDescription($description); + } if ((int) \Configuration::get(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION) && empty($order)) { return $payment; } - /** This param is not mandatory, but recommended **/ $payment->setOrderReference($order->reference); return $payment; diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 40959186..78a00b82 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -233,6 +233,10 @@ private function getGeneralSettingsTranslations() 'description' => $this->module->l('Description', self::FILE_NAME), 'enterDescription' => $this->module->l('Enter description', self::FILE_NAME), 'descriptionHelp' => $this->module->l('This description is visible in payment page also in payment confirmation email.', self::FILE_NAME), + 'orderReferenceOnPaymentPage' => $this->module->l('Order reference on payment page', self::FILE_NAME), + 'usePrestaShopOrderReference' => $this->module->l('Use PrestaShop Order reference (default)', self::FILE_NAME), + 'useDescriptionFieldValue' => $this->module->l('Use Description field value', self::FILE_NAME), + 'orderReferenceFallbackInfo' => html_entity_decode($this->module->l('When "Use PrestaShop Order reference" is selected and the order is not yet created (e.g. order creation after authorization), the Description field value is used as fallback.', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), 'debugMode' => $this->module->l('Debug mode', self::FILE_NAME), 'debugModeDescription' => $this->module->l('Enable debug mode to see more information in logs.', self::FILE_NAME), ]; diff --git a/views/js/admin/settings-app/src/components/settings/general-settings.tsx b/views/js/admin/settings-app/src/components/settings/general-settings.tsx index 70f74b73..6dac7f14 100644 --- a/views/js/admin/settings-app/src/components/settings/general-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -3,8 +3,9 @@ import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Settings2, Paintbrush, ClipboardList, Loader2 } from 'lucide-react' +import { Settings2, Paintbrush, ClipboardList, Loader2, Info } from 'lucide-react' import { useSettings } from '@/context/settings-context' import { t } from '@/utils/translations' @@ -97,17 +98,60 @@ export function GeneralSettings() {
-
- - updateSettings({ paymentDescription: e.target.value })} - /> -

- {t('descriptionHelp')} + {/* Order reference on payment page */} +

+ + updateSettings({ orderIdOption: Number(val) })} + className="sp-flex sp-flex-col sp-gap-3" + > + + + +
+ + {settings.orderIdOption === 1 && ( +
+ + updateSettings({ paymentDescription: e.target.value })} + /> +

+ {t('descriptionHelp')} +

+
+ )} + + {/* Info banner */} +
+ +

+ {t('orderReferenceFallbackInfo')}

diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 6abb19d3..47fd6f1f 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -126,6 +126,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, paymentDescription: currentSettings.paymentDescription, configurationName: currentSettings.configurationName, + orderIdOption: currentSettings.orderIdOption, debugMode: currentSettings.debugMode, }), 'General Settings', 'generalSettings') }, [handleSave]) diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index a8c88e05..af64ab59 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -54,6 +54,7 @@ export interface SaferpaySettingsData { orderStateAwaitingPayment: number paymentDescription: string configurationName: string + orderIdOption: number debugMode: boolean // Reference data From 64baef33c6637ff7e14326ea2d9a7af659dcd5f7 Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Tue, 17 Mar 2026 15:47:09 +0200 Subject: [PATCH 10/30] feat: add ConfigSet field validation, move hosted field style to general settings --- .../AdminSaferPayOfficialFieldsController.php | 90 ------------------- ...dminSaferPayOfficialSettingsController.php | 15 +++- .../01_ps1764.Module.Configure.cy.js | 8 -- .../01_ps1770.Module.Configure.cy.js | 8 -- .../01_ps1784.Module.Configure.cy.js | 8 -- .../01_ps1786.Module.Configure.cy.js | 8 -- saferpayofficial.php | 3 +- src/Entity/index.php | 31 ------- src/Install/AbstractInstaller.php | 6 -- src/Service/Request/RequestObjectCreator.php | 6 +- src/Service/SettingsTranslationService.php | 10 ++- translations/en.php | 0 upgrade/install-1.0.3.php | 7 -- .../install-2.1.1.php | 23 +++-- views/css/admin/saferpay_fields.css | 49 ---------- views/js/admin/saferpay_settings.js | 23 +++-- .../components/settings/general-settings.tsx | 48 +++++++++- .../src/context/settings-context.tsx | 1 + .../js/admin/settings-app/src/types/index.ts | 2 + .../field-option-settings/helpers/index.php | 31 ------- .../helpers/options/index.php | 31 ------- .../helpers/options/options.tpl | 70 --------------- .../admin/field-option-settings/index.php | 31 ------- 23 files changed, 108 insertions(+), 401 deletions(-) delete mode 100755 controllers/admin/AdminSaferPayOfficialFieldsController.php delete mode 100755 src/Entity/index.php create mode 100644 translations/en.php rename views/templates/admin/partials/field-hosted-field-template-desc.tpl => upgrade/install-2.1.1.php (75%) mode change 100755 => 100644 delete mode 100755 views/css/admin/saferpay_fields.css delete mode 100755 views/templates/admin/field-option-settings/helpers/index.php delete mode 100755 views/templates/admin/field-option-settings/helpers/options/index.php delete mode 100755 views/templates/admin/field-option-settings/helpers/options/options.tpl delete mode 100755 views/templates/admin/field-option-settings/index.php diff --git a/controllers/admin/AdminSaferPayOfficialFieldsController.php b/controllers/admin/AdminSaferPayOfficialFieldsController.php deleted file mode 100755 index 7508d598..00000000 --- a/controllers/admin/AdminSaferPayOfficialFieldsController.php +++ /dev/null @@ -1,90 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ - -use Invertus\SaferPay\Config\SaferPayConfig; - -require_once dirname(__FILE__) . '/../../vendor/autoload.php'; - -if (!defined('_PS_VERSION_')) { - exit; -} - -class AdminSaferPayOfficialFieldsController extends ModuleAdminController -{ - public function __construct() - { - parent::__construct(); - $this->bootstrap = true; - - $this->tpl_folder = 'field-option-settings/'; - $this->initOptions(); - } - - public function initContent() - { - parent::initContent(); - } - - public function initOptions() - { - $this->fields_options = [ - 'hosted_fields_settings' => [ - 'title' => $this->module->l('Hosted fields settings'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::HOSTED_FIELDS_TEMPLATE . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-hosted-field-template-desc.tpl', - ], - - SaferPayConfig::HOSTED_FIELDS_TEMPLATE => [ - 'type' => 'select-template', - 'name' => SaferPayConfig::HOSTED_FIELDS_TEMPLATE, - 'templateOptions' => [ - "{$this->module->getPathUri()}views/img/hosted-templates/template1.jpg", - "{$this->module->getPathUri()}views/img/hosted-templates/template2.jpg", - "{$this->module->getPathUri()}views/img/hosted-templates/template3.jpg", - ], - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ], - ]; - } - - public function setMedia($isNewTheme = false) - { - parent::setMedia($isNewTheme); - - $this->addJS('modules/' . $this->module->name . '/views/js/admin/saferpay_fields.js'); - $this->addCSS('modules/' . $this->module->name . '/views/css/admin/saferpay_fields.css'); - } -} diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index 70b8e14a..71ffd95e 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -312,7 +312,18 @@ public function ajaxProcessSaveGeneralSettings() $configuration->set(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT, $this->getIntValue($data, 'orderStateAwaitingPayment')); $configuration->set(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION, $this->getStringValue($data, 'paymentDescription')); - $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $this->getStringValue($data, 'configurationName')); + + $configurationName = $this->getStringValue($data, 'configurationName'); + if ($configurationName !== '' && (strlen($configurationName) > 20 || !preg_match('/^[A-Za-z0-9.:\-_]+$/', $configurationName))) { + $this->ajaxResponse(false, $this->module->l('Only letters, numbers, dots, colons, hyphens, and underscores are allowed. Max 20 characters.', self::FILE_NAME)); + return; + } + $configuration->set(SaferPayConfig::CONFIGURATION_NAME, $configurationName); + $hostedFieldsTemplate = $this->getIntValue($data, 'hostedFieldsTemplate'); + if ($hostedFieldsTemplate < 1 || $hostedFieldsTemplate > 3) { + $hostedFieldsTemplate = SaferPayConfig::HOSTED_FIELDS_TEMPLATE_DEFAULT; + } + $configuration->set(SaferPayConfig::HOSTED_FIELDS_TEMPLATE, $hostedFieldsTemplate); $configuration->set(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION, $this->getIntValue($data, 'orderIdOption')); $configuration->set(SaferPayConfig::SAFERPAY_DEBUG_MODE, !empty($data['debugMode']) ? 1 : 0); @@ -544,6 +555,8 @@ private function collectSettingsData() 'orderStateAwaitingPayment' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT), 'paymentDescription' => (string) $configuration->get(SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION), 'configurationName' => (string) $configuration->get(SaferPayConfig::CONFIGURATION_NAME), + 'hostedFieldsTemplate' => (int) $configuration->get(SaferPayConfig::HOSTED_FIELDS_TEMPLATE), + 'modulePath' => $this->module->getPathUri(), 'orderIdOption' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_ID_OPTION), 'debugMode' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_DEBUG_MODE), diff --git a/cypress/integration/01_ps1764.Module.Configure.cy.js b/cypress/integration/01_ps1764.Module.Configure.cy.js index b7313cb4..f8988d2b 100755 --- a/cypress/integration/01_ps1764.Module.Configure.cy.js +++ b/cypress/integration/01_ps1764.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1770.Module.Configure.cy.js b/cypress/integration/01_ps1770.Module.Configure.cy.js index 9696c496..1de0a18c 100755 --- a/cypress/integration/01_ps1770.Module.Configure.cy.js +++ b/cypress/integration/01_ps1770.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1784.Module.Configure.cy.js b/cypress/integration/01_ps1784.Module.Configure.cy.js index 8da2aae5..716aa483 100755 --- a/cypress/integration/01_ps1784.Module.Configure.cy.js +++ b/cypress/integration/01_ps1784.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/cypress/integration/01_ps1786.Module.Configure.cy.js b/cypress/integration/01_ps1786.Module.Configure.cy.js index 0c8259a9..863cb240 100755 --- a/cypress/integration/01_ps1786.Module.Configure.cy.js +++ b/cypress/integration/01_ps1786.Module.Configure.cy.js @@ -117,14 +117,6 @@ it('04 Fields and Logs tabs are shown OK', () => { cy.get('.pstaggerAddTagInput').type('saferpay') cy.get('#module-search-button').click() cy.get('.btn-group > .btn-primary-reverse').click() //clicking the Congifure - cy.get('#subtab-AdminSaferPayOfficialFields').click() - cy.get('[id="configuration_form"]').should('be.visible') - cy.get('.field-container > :nth-child(1) > img').click() - cy.get(':nth-child(2) > img').click() - cy.get(':nth-child(3) > img').click() - cy.get('[class="alert alert-info"]').should('be.visible') - cy.get('[name="submitOptionsconfiguration"]').click() - cy.get('[class="alert alert-success"]').should('be.visible') cy.get('#subtab-AdminSaferPayOfficialLogs').click() cy.get('[id="form-saferpay_log"]').should('be.visible') }) diff --git a/saferpayofficial.php b/saferpayofficial.php index b0acd463..dd997aa5 100755 --- a/saferpayofficial.php +++ b/saferpayofficial.php @@ -55,7 +55,6 @@ class SaferPayOfficial extends PaymentModule const ADMIN_SAFERPAY_MODULE_CONTROLLER = 'AdminSaferPayOfficialModule'; const ADMIN_SETTINGS_CONTROLLER = 'AdminSaferPayOfficialSettings'; const ADMIN_PAYMENTS_CONTROLLER = 'AdminSaferPayOfficialPayment'; - const ADMIN_FIELDS_CONTROLLER = 'AdminSaferPayOfficialFields'; const ADMIN_ORDER_CONTROLLER = 'AdminSaferPayOfficialOrder'; const ADMIN_LOGS_CONTROLLER = 'AdminSaferPayOfficialLogs'; @@ -65,7 +64,7 @@ public function __construct($name = null) { $this->name = 'saferpayofficial'; $this->author = 'Invertus'; - $this->version = '2.0.2'; + $this->version = '2.1.1'; $this->module_key = '3d3506c3e184a1fe63b936b82bda1bdf'; $this->displayName = 'SaferpayOfficial'; $this->description = 'Saferpay Payment module'; diff --git a/src/Entity/index.php b/src/Entity/index.php deleted file mode 100755 index ee622726..00000000 --- a/src/Entity/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ -header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); -header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - -header('Cache-Control: no-store, no-cache, must-revalidate'); -header('Cache-Control: post-check=0, pre-check=0', false); -header('Pragma: no-cache'); - -header('Location: ../'); -exit; diff --git a/src/Install/AbstractInstaller.php b/src/Install/AbstractInstaller.php index cbe8d897..2188841f 100755 --- a/src/Install/AbstractInstaller.php +++ b/src/Install/AbstractInstaller.php @@ -63,12 +63,6 @@ public function tabs() 'module_tab' => true, 'visible' => false, ], - [ - 'name' => $this->module->l('Fields'), - 'class_name' => SaferPayOfficial::ADMIN_FIELDS_CONTROLLER, - 'parent_class_name' => SaferPayOfficial::ADMIN_SAFERPAY_MODULE_CONTROLLER, - 'module_tab' => true, - ], [ 'name' => $this->module->l('Order'), 'class_name' => SaferPayOfficial::ADMIN_ORDER_CONTROLLER, diff --git a/src/Service/Request/RequestObjectCreator.php b/src/Service/Request/RequestObjectCreator.php index a5c40a19..21fc3983 100755 --- a/src/Service/Request/RequestObjectCreator.php +++ b/src/Service/Request/RequestObjectCreator.php @@ -128,12 +128,10 @@ public function createPayment(Cart $cart, $totalPrice) $payment->setDescription($description); } - if ((int) \Configuration::get(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION) && empty($order)) { - return $payment; + if (!empty($order)) { + $payment->setOrderReference($order->reference); } - $payment->setOrderReference($order->reference); - return $payment; } diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 78a00b82..58a315fb 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -188,7 +188,7 @@ private function getPaymentProcessingTranslations() 'createWhenAuthorized' => $this->module->l('Create when authorized', self::FILE_NAME), 'beforeAuthorization' => $this->module->l('Before authorization', self::FILE_NAME), 'createBeforePayment' => $this->module->l('Create before payment', self::FILE_NAME), - 'cardDisplaySaving' => $this->module->l('Card Display & Saving', self::FILE_NAME), + 'cardDisplaySaving' => html_entity_decode($this->module->l('Card Display & Saving', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), 'cardDisplayDescription' => $this->module->l('Configure how cards appear at checkout and whether customers can save them.', self::FILE_NAME), 'groupCardsLabel' => $this->module->l('Group debit/credit cards as \'Cards\' in checkout', self::FILE_NAME), 'groupCardsDescription' => $this->module->l('If enabled, all supported card brands will be grouped and shown as a single \'Cards\' payment method at checkout.', self::FILE_NAME), @@ -227,7 +227,13 @@ private function getGeneralSettingsTranslations() 'stylingDescription' => $this->module->l('Customize the appearance of the payment page.', self::FILE_NAME), 'configName' => $this->module->l('Payment Page configurations name', self::FILE_NAME), 'enterConfigName' => $this->module->l('Enter configuration name', self::FILE_NAME), - 'configNameDescription' => $this->module->l('This name is visible in payment page and also in payment confirmation email.', self::FILE_NAME), + 'configNameDescription' => html_entity_decode($this->module->l('Name of the Payment Page Configuration created in Saferpay Backoffice (Settings > Payment Page Configuration). Max 20 characters. Allowed: letters, numbers, dots, colons, hyphens, underscores.', self::FILE_NAME), ENT_QUOTES, 'UTF-8'), + 'hostedFieldInfo' => $this->module->l('Choose which hosted field will be displayed on payment option selection with supported payment methods.', self::FILE_NAME), + 'hostedFieldStyle' => $this->module->l('Hosted field style', self::FILE_NAME), + 'hostedFieldStyleDescription' => $this->module->l('Select the card input form layout for the payment page.', self::FILE_NAME), + 'classicLayout' => $this->module->l('Classic Layout', self::FILE_NAME), + 'labeledLayout' => $this->module->l('Labeled Layout', self::FILE_NAME), + 'inlineLayoutWithCard' => $this->module->l('Inline Layout with Card', self::FILE_NAME), 'configuration' => $this->module->l('Configuration', self::FILE_NAME), 'configurationDescription' => $this->module->l('General module configuration settings.', self::FILE_NAME), 'description' => $this->module->l('Description', self::FILE_NAME), diff --git a/translations/en.php b/translations/en.php new file mode 100644 index 00000000..e69de29b diff --git a/upgrade/install-1.0.3.php b/upgrade/install-1.0.3.php index 2574c76e..b4938c7f 100755 --- a/upgrade/install-1.0.3.php +++ b/upgrade/install-1.0.3.php @@ -57,12 +57,5 @@ function upgrade_module_1_0_3($module) ADD COLUMN `authorized` TINYINT(1) DEFAULT 0' ); - $installer = new \Invertus\SaferPay\Install\Installer($module); - $installer->installTab( - SaferPayOfficial::ADMIN_FIELDS_CONTROLLER, - SaferPayOfficial::ADMIN_SAFERPAY_MODULE_CONTROLLER, - $module->l('Fields') - ); - return $result; } diff --git a/views/templates/admin/partials/field-hosted-field-template-desc.tpl b/upgrade/install-2.1.1.php old mode 100755 new mode 100644 similarity index 75% rename from views/templates/admin/partials/field-hosted-field-template-desc.tpl rename to upgrade/install-2.1.1.php index 8314405e..b7b39ae0 --- a/views/templates/admin/partials/field-hosted-field-template-desc.tpl +++ b/upgrade/install-2.1.1.php @@ -1,4 +1,5 @@ -{** + *@copyright SIX Payment Services *@license SIX Payment Services - *} -
- {l s='Choose which hosted field will be displayed on payment option selection with supported payment methods' mod='saferpayofficial'} -
+ */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +function upgrade_module_2_1_1() +{ + $tabId = Tab::getIdFromClassName('AdminSaferPayOfficialFields'); + if ($tabId) { + $tab = new Tab($tabId); + $tab->delete(); + } + + return true; +} diff --git a/views/css/admin/saferpay_fields.css b/views/css/admin/saferpay_fields.css deleted file mode 100755 index 064c2c33..00000000 --- a/views/css/admin/saferpay_fields.css +++ /dev/null @@ -1,49 +0,0 @@ -/** - *NOTICE OF LICENSE - * - *This source file is subject to the Open Software License (OSL 3.0) - *that is bundled with this package in the file LICENSE.txt. - *It is also available through the world-wide-web at this URL: - *http://opensource.org/licenses/osl-3.0.php - *If you did not receive a copy of the license and are unable to - *obtain it through the world-wide-web, please send an email - *to license@prestashop.com so we can send you a copy immediately. - * - *DISCLAIMER - * - * Do not edit or add to this file if you wish to upgrade PrestaShop to newer - *versions in the future. If you wish to customize PrestaShop for your - *needs please refer to http://www.prestashop.com for more information. - * - *@author INVERTUS UAB www.invertus.eu - *@copyright SIX Payment Services - *@license SIX Payment Services - */ - -/* HIDE RADIO */ -[type=radio] { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -/* IMAGE STYLES */ -[type=radio] + img { - cursor: pointer; -} - -/* CHECKED STYLES */ -[type=radio]:checked + img { - outline: 2px solid #f00; -} - -.field-label { - flex: 0 0 23%; - margin-bottom:30px !important; -} - -.field-container { - display: flex; - flex-wrap: wrap; -} diff --git a/views/js/admin/saferpay_settings.js b/views/js/admin/saferpay_settings.js index 6407bc17..72f482b1 100755 --- a/views/js/admin/saferpay_settings.js +++ b/views/js/admin/saferpay_settings.js @@ -20,16 +20,23 @@ *@license SIX Payment Services */ -$(document).ready(function (e) { - $("input[name='SAFERPAY_CONFIGURATION_NAME']").keypress(function (e) { - //disable symbols +$(document).ready(function () { + var $configInput = $("input[name='SAFERPAY_CONFIGURATION_NAME']"); + + $configInput.attr('maxlength', 20); + + $configInput.keypress(function (e) { var txt = String.fromCharCode(e.which); - if (!txt.match(/[A-Za-z0-9&. ]/)) { - return false; - } - // disable space - if (e.keyCode === 32) { + if (!txt.match(/[A-Za-z0-9.:\-_]/)) { return false; } }); + + $configInput.on('paste', function (e) { + var $input = $(this); + setTimeout(function () { + var cleaned = $input.val().replace(/[^A-Za-z0-9.:\-_]/g, '').substring(0, 20); + $input.val(cleaned); + }, 0); + }); }); \ No newline at end of file diff --git a/views/js/admin/settings-app/src/components/settings/general-settings.tsx b/views/js/admin/settings-app/src/components/settings/general-settings.tsx index 6dac7f14..fc077be4 100644 --- a/views/js/admin/settings-app/src/components/settings/general-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -72,14 +72,60 @@ export function GeneralSettings() { updateSettings({ configurationName: e.target.value })} + onChange={(e) => { + const cleaned = e.target.value.replace(/[^A-Za-z0-9.:\-_]/g, '') + updateSettings({ configurationName: cleaned }) + }} />

{t('configNameDescription')}

+ + {/* Hosted field info banner */} +
+ +

+ {t('hostedFieldInfo')} +

+
+ + {/* Hosted field style selector */} +
+
+ + +

+ {t('hostedFieldStyleDescription')} +

+
+
+ { +
+
diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx index 47fd6f1f..c4ce8f6c 100644 --- a/views/js/admin/settings-app/src/context/settings-context.tsx +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -126,6 +126,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) { orderStateAwaitingPayment: currentSettings.orderStateAwaitingPayment, paymentDescription: currentSettings.paymentDescription, configurationName: currentSettings.configurationName, + hostedFieldsTemplate: currentSettings.hostedFieldsTemplate, orderIdOption: currentSettings.orderIdOption, debugMode: currentSettings.debugMode, }), 'General Settings', 'generalSettings') diff --git a/views/js/admin/settings-app/src/types/index.ts b/views/js/admin/settings-app/src/types/index.ts index af64ab59..436600c1 100644 --- a/views/js/admin/settings-app/src/types/index.ts +++ b/views/js/admin/settings-app/src/types/index.ts @@ -54,6 +54,8 @@ export interface SaferpaySettingsData { orderStateAwaitingPayment: number paymentDescription: string configurationName: string + hostedFieldsTemplate: number + modulePath: string orderIdOption: number debugMode: boolean diff --git a/views/templates/admin/field-option-settings/helpers/index.php b/views/templates/admin/field-option-settings/helpers/index.php deleted file mode 100755 index ee622726..00000000 --- a/views/templates/admin/field-option-settings/helpers/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ -header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); -header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - -header('Cache-Control: no-store, no-cache, must-revalidate'); -header('Cache-Control: post-check=0, pre-check=0', false); -header('Pragma: no-cache'); - -header('Location: ../'); -exit; diff --git a/views/templates/admin/field-option-settings/helpers/options/index.php b/views/templates/admin/field-option-settings/helpers/options/index.php deleted file mode 100755 index ee622726..00000000 --- a/views/templates/admin/field-option-settings/helpers/options/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ -header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); -header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - -header('Cache-Control: no-store, no-cache, must-revalidate'); -header('Cache-Control: post-check=0, pre-check=0', false); -header('Pragma: no-cache'); - -header('Location: ../'); -exit; diff --git a/views/templates/admin/field-option-settings/helpers/options/options.tpl b/views/templates/admin/field-option-settings/helpers/options/options.tpl deleted file mode 100755 index 4c100589..00000000 --- a/views/templates/admin/field-option-settings/helpers/options/options.tpl +++ /dev/null @@ -1,70 +0,0 @@ -{** - *NOTICE OF LICENSE - * - *This source file is subject to the Open Software License (OSL 3.0) - *that is bundled with this package in the file LICENSE.txt. - *It is also available through the world-wide-web at this URL: - *http://opensource.org/licenses/osl-3.0.php - *If you did not receive a copy of the license and are unable to - *obtain it through the world-wide-web, please send an email - *to license@prestashop.com so we can send you a copy immediately. - * - *DISCLAIMER - * - * Do not edit or add to this file if you wish to upgrade PrestaShop to newer - *versions in the future. If you wish to customize PrestaShop for your - *needs please refer to http://www.prestashop.com for more information. - * - *@author INVERTUS UAB www.invertus.eu - *@copyright SIX Payment Services - *@license SIX Payment Services - *} - -{extends file="helpers/options/options.tpl"} - -{block name="input" append} - {if $field['type'] == 'password_input'} -
- -
- {/if} - {if $field['type'] == 'desc'} -
- {if $field['template'] == 'field-javascript-library-desc.tpl'} - {include file="../../../partials/field-javascript-library-desc.tpl"} - {/if} - - {if $field['template'] == 'field-access-token-desc.tpl'} - {include file="../../../partials/field-access-token-desc.tpl"} - {/if} - - {if $field['template'] == 'field-hosted-field-template-desc.tpl'} - {include file="../../../partials/field-hosted-field-template-desc.tpl"} - {/if} - {if $field['template'] == 'field-new-order-mail-desc.tpl'} - {include file="../../../partials/field-new-order-mail-desc.tpl"} - {/if} -
- {/if} - - {if $field['type'] == 'select-template'} - -
- {foreach from=$field['templateOptions'] key=key item=templateUrl} - {assign var='key' value=$key + 1} {* To have normal keys without 0 *} - - {/foreach} -
- - {/if} -{/block} diff --git a/views/templates/admin/field-option-settings/index.php b/views/templates/admin/field-option-settings/index.php deleted file mode 100755 index ee622726..00000000 --- a/views/templates/admin/field-option-settings/index.php +++ /dev/null @@ -1,31 +0,0 @@ - - *@copyright SIX Payment Services - *@license SIX Payment Services - */ -header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); -header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - -header('Cache-Control: no-store, no-cache, must-revalidate'); -header('Cache-Control: post-check=0, pre-check=0', false); -header('Pragma: no-cache'); - -header('Location: ../'); -exit; From a766c174fed3508b6f694bb69972ab0943a784de Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Fri, 20 Mar 2026 14:10:19 +0200 Subject: [PATCH 11/30] feat: add accessibility improvements for EAA compliance --- src/Service/SettingsTranslationService.php | 1 + views/css/admin/logs_tab.css | 23 +++++++++++++- views/css/admin/payment_method.css | 6 ++++ views/js/admin/log.js | 31 +++++++++++++++++-- .../components/settings/api-credentials.tsx | 4 +-- .../settings/email-notifications.tsx | 2 +- .../components/settings/general-settings.tsx | 2 +- .../components/settings/payment-methods.tsx | 4 +-- .../settings/payment-processing.tsx | 2 +- views/templates/admin/logs/log_modal.tpl | 12 ++++--- views/templates/front/credit_card.tpl | 5 +-- views/templates/front/credit_cards.tpl | 6 +++- .../hosted-templates/partials/all_errors.tpl | 8 ++--- .../partials/all_errors_16.tpl | 8 ++--- .../front/hosted-templates/template1.tpl | 24 +++++++++----- .../front/hosted-templates/template3.tpl | 26 ++++++++++------ views/templates/front/loading.tpl | 4 ++- views/templates/front/saferpay_iframe.tpl | 4 +-- views/templates/front/saferpay_wait.tpl | 3 +- views/templates/hook/admin/saferpay_order.tpl | 3 ++ .../hook/front/payment_with_cards.tpl | 4 +-- .../hook/front/saferpay_additional_info.tpl | 26 ++++++++++------ .../templates/hook/front/saferpay_payment.tpl | 2 +- 23 files changed, 149 insertions(+), 61 deletions(-) diff --git a/src/Service/SettingsTranslationService.php b/src/Service/SettingsTranslationService.php index 58a315fb..d657fdb0 100644 --- a/src/Service/SettingsTranslationService.php +++ b/src/Service/SettingsTranslationService.php @@ -85,6 +85,7 @@ private function getCommonTranslations() { return [ 'saveChanges' => $this->module->l('Save Changes', self::FILE_NAME), + 'saving' => $this->module->l('Saving...', self::FILE_NAME), 'enable' => $this->module->l('Enable', self::FILE_NAME), 'disable' => $this->module->l('Disable', self::FILE_NAME), 'search' => $this->module->l('Search...', self::FILE_NAME), diff --git a/views/css/admin/logs_tab.css b/views/css/admin/logs_tab.css index 5bb36a92..702cff1e 100755 --- a/views/css/admin/logs_tab.css +++ b/views/css/admin/logs_tab.css @@ -73,10 +73,31 @@ border-bottom: solid 1px grey; pointer-events: all; display: flex; - justify-content: center; + justify-content: space-between; + align-items: center; max-height: 10vh; } +.log-modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + line-height: 1; + color: #6b7280; + margin-right: 0.5rem; +} + +.log-modal-close:hover { + color: #111827; +} + +.log-modal-close:focus { + outline: 2px solid #2196F3; + outline-offset: 2px; +} + .log-modal-content { padding: 15px; height: 50vh; diff --git a/views/css/admin/payment_method.css b/views/css/admin/payment_method.css index 336eda6f..9b0b08c9 100755 --- a/views/css/admin/payment_method.css +++ b/views/css/admin/payment_method.css @@ -43,6 +43,12 @@ width: 0; } +/* Visible focus indicator for keyboard navigation */ +.container-checkbox input:focus ~ .checkmark { + outline: 2px solid #2196F3; + outline-offset: 2px; +} + /* Create a custom checkbox */ .checkmark { position: absolute; diff --git a/views/js/admin/log.js b/views/js/admin/log.js index 4a8eb769..481b07f9 100644 --- a/views/js/admin/log.js +++ b/views/js/admin/log.js @@ -21,17 +21,44 @@ */ $(document).ready(function () { + function closeModal($modal) { + $modal.removeClass('open'); + var triggerButton = $modal.data('triggerButton'); + if (triggerButton) { + triggerButton.focus(); + } + } + $('.log-modal-overlay').on('click', function (event) { - $('.modal.open').removeClass('open'); + closeModal($(this).closest('.modal')); + event.preventDefault(); + }); + + $('.js-log-modal-close').on('click', function (event) { + closeModal($(this).closest('.modal')); event.preventDefault(); }); + $(document).on('keydown', function (event) { + if (event.key === 'Escape') { + var $openModal = $('.modal.open'); + if ($openModal.length) { + closeModal($openModal); + event.preventDefault(); + } + } + }); + $('.js-log-button').on('click', function (event) { var logId = $(this).data('log-id'); var informationType = $(this).data('information-type'); + var $modal = $('#' + $(this).data('target')); + + $modal.data('triggerButton', $(this)); // NOTE: opening modal - $('#' + $(this).data('target')).addClass('open'); + $modal.addClass('open'); + $modal.find('.js-log-modal-close').focus(); // NOTE: if information has been set already we don't need to call ajax again. if (!$('#log-modal-' + logId + '-' + informationType + ' .log-modal-content-data').hasClass('hidden')) { diff --git a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx index 5a393190..15484be1 100644 --- a/views/js/admin/settings-app/src/components/settings/api-credentials.tsx +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -168,7 +168,7 @@ export function ApiCredentials() {
diff --git a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx index 044239c8..d79d331a 100644 --- a/views/js/admin/settings-app/src/components/settings/email-notifications.tsx +++ b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx @@ -90,7 +90,7 @@ export function EmailNotifications() {
-
diff --git a/views/js/admin/settings-app/src/components/settings/general-settings.tsx b/views/js/admin/settings-app/src/components/settings/general-settings.tsx index fc077be4..8a43d815 100644 --- a/views/js/admin/settings-app/src/components/settings/general-settings.tsx +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -221,7 +221,7 @@ export function GeneralSettings() {
-
diff --git a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx index bc3fc449..01149c2a 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-methods.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx @@ -48,7 +48,7 @@ function MultiSelect({
diff --git a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx index c19e98a4..3d26f72d 100644 --- a/views/js/admin/settings-app/src/components/settings/payment-processing.tsx +++ b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx @@ -305,7 +305,7 @@ export function PaymentProcessing() {
-
diff --git a/views/templates/admin/logs/log_modal.tpl b/views/templates/admin/logs/log_modal.tpl index 62cb43dd..eadfc7fc 100644 --- a/views/templates/admin/logs/log_modal.tpl +++ b/views/templates/admin/logs/log_modal.tpl @@ -19,7 +19,8 @@ *@copyright SIX Payment Services *@license SIX Payment Services *} -
{l s='View' mod='saferpayofficial'} -
+ -