From cdc0909ea19f8b17d6acbbb63c43e9ce9b582a71 Mon Sep 17 00:00:00 2001 From: Justas Date: Tue, 24 Feb 2026 13:52:38 +0200 Subject: [PATCH 01/61] UI redesign --- .gitignore | 2 + CLAUDE.md | 134 + Makefile | 6 + ...AdminSaferPayOfficialPaymentController.php | 297 +- ...dminSaferPayOfficialSettingsController.php | 859 +++--- force_https.sql | 11 + pnpm-lock.yaml | 1367 +++++++++ src/Api/ApiRequest.php | 54 + src/Api/Request/GetTerminalsService.php | 59 + .../GetTerminals/GetTerminalsRequest.php | 58 + src/Install/AbstractInstaller.php | 1 + src/Service/SaferPayGetTerminals.php | 80 + upgrade/install-2.1.0.php | 37 + views/js/admin/settings-app/index.html | 34 + views/js/admin/settings-app/package.json | 39 + views/js/admin/settings-app/pnpm-lock.yaml | 2597 +++++++++++++++++ .../js/admin/settings-app/postcss.config.mjs | 6 + views/js/admin/settings-app/src/App.tsx | 10 + views/js/admin/settings-app/src/api/client.ts | 59 + .../components/settings/api-credentials.tsx | 300 ++ .../settings/email-notifications.tsx | 97 + .../components/settings/general-settings.tsx | 138 + .../components/settings/payment-methods.tsx | 299 ++ .../settings/payment-processing.tsx | 296 ++ .../components/settings/saferpay-settings.tsx | 85 + .../components/settings/toast-container.tsx | 25 + .../settings-app/src/components/ui/alert.tsx | 59 + .../settings-app/src/components/ui/badge.tsx | 34 + .../settings-app/src/components/ui/button.tsx | 58 + .../settings-app/src/components/ui/card.tsx | 79 + .../src/components/ui/checkbox.tsx | 28 + .../settings-app/src/components/ui/input.tsx | 22 + .../settings-app/src/components/ui/label.tsx | 24 + .../src/components/ui/popover.tsx | 29 + .../src/components/ui/radio-group.tsx | 42 + .../settings-app/src/components/ui/select.tsx | 156 + .../src/components/ui/separator.tsx | 29 + .../settings-app/src/components/ui/switch.tsx | 27 + .../settings-app/src/components/ui/tabs.tsx | 53 + .../src/context/settings-context.tsx | 162 + views/js/admin/settings-app/src/globals.css | 42 + .../settings-app/src/hooks/use-mobile.tsx | 19 + .../admin/settings-app/src/hooks/use-toast.ts | 103 + views/js/admin/settings-app/src/lib/utils.ts | 6 + views/js/admin/settings-app/src/main.tsx | 15 + .../js/admin/settings-app/src/types/index.ts | 76 + .../js/admin/settings-app/tailwind.config.ts | 71 + views/js/admin/settings-app/tsconfig.json | 24 + views/js/admin/settings-app/vite.config.ts | 23 + views/templates/admin/settings_react.tpl | 4 + 50 files changed, 7428 insertions(+), 707 deletions(-) create mode 100644 CLAUDE.md create mode 100644 force_https.sql create mode 100644 pnpm-lock.yaml create mode 100644 src/Api/Request/GetTerminalsService.php create mode 100644 src/DTO/Request/GetTerminals/GetTerminalsRequest.php create mode 100644 src/Service/SaferPayGetTerminals.php create mode 100644 upgrade/install-2.1.0.php create mode 100644 views/js/admin/settings-app/index.html create mode 100644 views/js/admin/settings-app/package.json create mode 100644 views/js/admin/settings-app/pnpm-lock.yaml create mode 100644 views/js/admin/settings-app/postcss.config.mjs create mode 100644 views/js/admin/settings-app/src/App.tsx create mode 100644 views/js/admin/settings-app/src/api/client.ts create mode 100644 views/js/admin/settings-app/src/components/settings/api-credentials.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/email-notifications.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/general-settings.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/payment-methods.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/payment-processing.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx create mode 100644 views/js/admin/settings-app/src/components/settings/toast-container.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/alert.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/badge.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/button.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/card.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/checkbox.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/input.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/label.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/popover.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/radio-group.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/select.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/separator.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/switch.tsx create mode 100644 views/js/admin/settings-app/src/components/ui/tabs.tsx create mode 100644 views/js/admin/settings-app/src/context/settings-context.tsx create mode 100644 views/js/admin/settings-app/src/globals.css create mode 100644 views/js/admin/settings-app/src/hooks/use-mobile.tsx create mode 100644 views/js/admin/settings-app/src/hooks/use-toast.ts create mode 100644 views/js/admin/settings-app/src/lib/utils.ts create mode 100644 views/js/admin/settings-app/src/main.tsx create mode 100644 views/js/admin/settings-app/src/types/index.ts create mode 100644 views/js/admin/settings-app/tailwind.config.ts create mode 100644 views/js/admin/settings-app/tsconfig.json create mode 100644 views/js/admin/settings-app/vite.config.ts create mode 100644 views/templates/admin/settings_react.tpl diff --git a/.gitignore b/.gitignore index c17ac67f2..0e015ceb0 100755 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ tests/.env var/cache/* !var/cache/index.php .DS_Store +views/js/admin/settings-app/node_modules/ +views/js/admin/dist/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..9619de25b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +SaferPay Official is a PrestaShop payment module integrating SIX Payment Services (Worldline). It supports 23+ payment methods (credit cards, digital wallets, bank transfers, Klarna, PayPal, etc.) with features like 3DS, saved card tokenization, and partial refunds. + +- **Namespace:** `Invertus\SaferPay` +- **PrestaShop compatibility:** 1.7.6.1+ +- **PHP platform target:** 5.6 (via composer config) +- **Current version:** 2.0.2 + +## Build & Development Commands + +```bash +# Install dependencies +composer install + +# Run unit tests +composer test-unit +# or: ./vendor/bin/phpunit --configuration tests/Unit/phpunit.xml + +# Run integration tests +composer test-integration + +# Lint (auto-fix with PHP CS Fixer) +make fix-lint # via Docker +./vendor/bin/php-cs-fixer fix # directly + +# Lint dry-run (CI check) +./vendor/bin/php-cs-fixer fix --diff --no-interaction --dry-run + +# PHPStan static analysis (needs _PS_ROOT_DIR_ set) +_PS_ROOT_DIR_=/var/www/html ./vendor/bin/phpstan --configuration=tests/phpstan/phpstan.neon analyse + +# Build production ZIP +make prepare-zip + +# Docker E2E environment (PS 1.7.8.4 example) +make e2e1784p # start containers + seed DB + install module +make bps1784 # just build/install module in running container +``` + +Version-specific PHPStan configs exist at `tests/phpstan/phpstan-{version}.neon` (e.g., `phpstan-1784.neon`). + +## Code Style + +Enforced by `.php_cs.dist` — PSR-2 plus: +- Short array syntax `[]` +- Trailing commas in multiline arrays +- Braces on next line for functions/OOP constructs +- Single space around concatenation (`.`) +- No unused imports, no useless else/return + +## Architecture + +### Entry Point + +`saferpayofficial.php` — Main module class `SaferPayOfficial extends PaymentModule`. Registers hooks, handles install/uninstall, and provides `$this->getService($serviceName)` for DI. + +### Dependency Injection + +Uses **League Container** with auto-wiring via `ReflectionContainer`. Interface-to-implementation bindings are registered in `src/ServiceProvider/BaseServiceProvider.php`. The container is created fresh per `getService()` call in `LeagueServiceContainerProvider`. Services can be overridden via `extend($id, $concrete)` for testing. + +### src/ Layout + +| Directory | Purpose | +|-----------|---------| +| `Adapter/` | Wrappers around PrestaShop globals (Context, Configuration, Tools, Cart) | +| `Api/` | HTTP client (`ApiRequest`) and API service classes (Initialize, Assert, Capture, Refund, Cancel) | +| `Config/SaferPayConfig.php` | All constants: API URLs, config keys, payment method definitions, hook list | +| `Controller/` | Abstract base controllers for admin and front | +| `Core/` | Order actions, payment processing, verification logic | +| `DTO/` | Request and Response data transfer objects for the SaferPay API | +| `Entity/` | PrestaShop ObjectModel entities (12 tables: `saferpay_order`, `saferpay_payment`, `saferpay_card_alias`, etc.) | +| `EntityBuilder/` | Construct entities from API responses | +| `EntityManager/` | Persistence abstraction (`ObjectModelEntityManager`) | +| `Exception/` | Domain exceptions with error codes | +| `Factory/` | Object creation | +| `Install/` | `Installer` and `Uninstaller` — DB table creation, hook registration, default config | +| `Logger/` | Logging to `saferpay_log` table with `LogFormatter` | +| `Presenter/` | Prepare data for templates (admin order page, assertions) | +| `Processor/` | Request processing handlers | +| `Provider/` | Payment type info, restrictions, currencies, redirect URLs | +| `Repository/` | ~20 repository classes for data access, with interface abstractions | +| `Service/` | ~27 service classes: payment init, status updates, mail, cart ops, restriction validation, transaction flows | +| `ServiceProvider/` | League Container setup and base service registration | +| `Utility/` | Helper functions | +| `Validation/` | Input validation | + +### Controllers + +- **Admin** (`controllers/admin/`, 6 controllers): Settings, Payment management, Logs, Order details, Fields, Module redirect +- **Front** (`controllers/front/`, 15 controllers): Payment flow (ajax, validation, iframe, hostedIframe, return, notify), success/fail pages, saved credit cards, pending notifications + +### Payment Transaction Flow + +1. **Initialize** → `SaferPayInitialize` / `InitializeService` — create payment session, redirect customer +2. **Assert** → `SaferPayTransactionAssertion` / `AssertService` — verify payment after return +3. **Authorize/Capture** → `SaferPayTransactionAuthorization` / `AuthorizationService` / `CaptureService` +4. **Refund** → `SaferPayTransactionRefundAssertion` / `RefundService` +5. **Webhook notifications** → `notify.php` / `pendingNotify.php` controllers + +### Database + +12 custom tables prefixed with `saferpay_` (order, payment, card_alias, field, logo, restriction, assert, assert_refund, order_refund, log, country, currency). Schema managed in `src/Install/Installer.php`. Version migrations in `upgrade/install-{version}.php`. + +### Templates & Assets + +Smarty `.tpl` templates in `views/templates/` (admin, front, hook subdirectories). CSS/JS assets in `views/css/` and `views/js/` with admin/front separation. + +## Testing + +- **Unit tests:** `tests/Unit/` — PHPUnit with `tests/Unit/bootstrap.php`; base class `UnitTestCase` +- **Integration tests:** `tests/Integration/` +- **E2E tests:** `cypress/` — Cypress 9.x, run via BrowserStack in CI across PS versions +- **Test DB seeds:** `tests/seed/database/` — SQL dumps per PrestaShop version + +## CI/CD + +GitHub Actions in `.github/workflows/`: +- **cd.yml**: Lint + PHPStan matrix across PS versions, then build artifact ZIP +- **Cypress workflows**: Per-version E2E tests on BrowserStack +- PRs should target the latest release branch (e.g., `release-2.0.0`) + +## Key Configuration + +`src/Config/SaferPayConfig.php` contains: +- API endpoints (test: `test.saferpay.com/api/`, live: `www.saferpay.com/api/`) +- API version constant (`1.45`) +- All `Configuration` key names (`SAFERPAY_TEST_MODE`, `SAFERPAY_USERNAME`, etc.) +- Payment method name constants and supported methods lists +- Hook names the module registers diff --git a/Makefile b/Makefile index 5a9a74a41..6d7ccb15a 100755 --- a/Makefile +++ b/Makefile @@ -144,11 +144,17 @@ e2eh1786: test-e2e-headless-1786 test-e2e-headless-1786: make e2e1786p +build-react: + cd views/js/admin/settings-app && pnpm install && pnpm run build + prepare-zip: rm -rf vendor && \ composer install --no-dev --optimize-autoloader && \ cp .github/.htaccess vendor/.htaccess && \ + cd views/js/admin/settings-app && pnpm install && pnpm run build && cd ../../../.. && \ rm -rf .git .github tests cypress .docker && \ + rm -rf views/js/admin/settings-app/node_modules && \ + rm -rf views/js/admin/settings-app/src && \ mkdir saferpayofficial && \ rsync -Rr ./ ./saferpayofficial && \ find . -maxdepth 1 ! -name saferpayofficial -exec mv {} saferpayofficial/ \; && \ diff --git a/controllers/admin/AdminSaferPayOfficialPaymentController.php b/controllers/admin/AdminSaferPayOfficialPaymentController.php index a574e0756..105ce2eb7 100755 --- a/controllers/admin/AdminSaferPayOfficialPaymentController.php +++ b/controllers/admin/AdminSaferPayOfficialPaymentController.php @@ -27,305 +27,14 @@ exit; } -use Invertus\SaferPay\Config\SaferPayConfig; -use Invertus\SaferPay\Exception\Restriction\RestrictionException; -use Invertus\SaferPay\Repository\SaferPayFieldRepository; -use Invertus\SaferPay\Repository\SaferPayLogoRepository; -use Invertus\SaferPay\Repository\SaferPayPaymentRepository; -use Invertus\SaferPay\Repository\SaferPayRestrictionRepository; -use Invertus\SaferPay\Service\SaferPayExceptionService; -use Invertus\SaferPay\Service\SaferPayFieldCreator; -use Invertus\SaferPay\Service\SaferPayLogoCreator; -use Invertus\SaferPay\Service\SaferPayPaymentCreator; -use Invertus\SaferPay\Service\SaferPayPaymentNotation; -use Invertus\SaferPay\Service\SaferPayRestrictionCreator; -use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; -use Invertus\SaferPay\Service\SaferPayRefreshPaymentsService; -use Invertus\SaferPay\Exception\Api\SaferPayApiException; - class AdminSaferPayOfficialPaymentController extends ModuleAdminController { /** @var \SaferPayOfficial */ public $module; - public function __construct() - { - parent::__construct(); - $this->bootstrap = true; - } - - public function setMedia($isNewTheme = false) - { - parent::setMedia($isNewTheme); - - $this->addCSS('modules/' . $this->module->name . '/views/css/admin/payment_method.css'); - $this->addJS('modules/' . $this->module->name . '/views/js/admin/chosen_countries.js'); - $this->addJS('modules/' . $this->module->name . '/views/js/admin/payment_method_all.js'); - } - - /** - * Custom form processing - */ - public function postProcess() - { - // Refresh payments. - /** @var SaferPayRefreshPaymentsService $refreshPaymentsService */ - $refreshPaymentsService = $this->module->getService(SaferPayRefreshPaymentsService::class); - try { - $refreshPaymentsService->refreshPayments(); - } catch (SaferPayApiException $exception) { - $this->errors[] = $this->module->l($exception->getMessage()); - } - - if (!Tools::isSubmit('submitAddconfiguration')) { - return parent::postProcess(); - } - - /** @var SaferPayPaymentCreator $paymentCreation */ - $paymentCreation = $this->module->getService(SaferPayPaymentCreator::class); - - /** @var SaferPayLogoCreator $logoCreation */ - $logoCreation = $this->module->getService(SaferPayLogoCreator::class); - - /** @var SaferPayFieldCreator $fieldCreation */ - $fieldCreation = $this->module->getService(SaferPayFieldCreator::class); - - /** @var SaferPayRestrictionCreator $restrictionCreator */ - $restrictionCreator = $this->module->getService(SaferPayRestrictionCreator::class); - - $paymentMethods = $this->getPaymentMethods(); - if (is_null($paymentMethods)) { - return; - } - - $success = true; - foreach ($paymentMethods as $paymentMethod) { - $isActive = Tools::getValue($paymentMethod . '_enable'); - $success &= $paymentCreation->updatePayment($paymentMethod, $isActive); - - $isActive = Tools::getValue($paymentMethod . '_logo'); - $success &= $logoCreation->updateLogo($paymentMethod, $isActive); - - $isActive = Tools::getValue($paymentMethod . '_field'); - $success &= $fieldCreation->updateField($paymentMethod, $isActive); - - try { - $success &= $restrictionCreator->updateRestriction( - $paymentMethod, - SaferPayRestrictionCreator::RESTRICTION_COUNTRY, - Tools::getValue($paymentMethod . SaferPayRestrictionCreator::COUNTRY_SUFFIX) - ); - $success &= $restrictionCreator->updateRestriction( - $paymentMethod, - SaferPayRestrictionCreator::RESTRICTION_CURRENCY, - Tools::getValue($paymentMethod . SaferPayRestrictionCreator::CURRENCY_SUFFIX) - ); - } catch (RestrictionException $e) { - $this->errors[] = $this->module->l('Wrong restriction type'); - $success = false; - } - } - - if (!$success) { - $this->errors[] = $this->module->l('Failed update'); - } else { - $this->confirmations[] = $this->module->l('Successful update'); - } - - return true; - } - - public function initContent() - { - parent::initContent(); - - $this->content .= $this->renderShoppingPointOptions(); - $this->context->smarty->assign('content', $this->content); - } - - protected function renderShoppingPointOptions() - { - $referralOptionsForm = new HelperForm(); - - /** @var SaferPayPaymentRepository $paymentRepository */ - $paymentRepository = $this->module->getService(SaferPayPaymentRepository::class); - - /** @var SaferPayLogoRepository $logoRepository */ - $logoRepository = $this->module->getService(SaferPayLogoRepository::class); - - /** @var SaferPayFieldRepository $fieldRepository */ - $fieldRepository = $this->module->getService(SaferPayFieldRepository::class); - - /** @var SaferPayRestrictionRepository $restrictionRepository */ - $restrictionRepository = $this->module->getService(SaferPayRestrictionRepository::class); - - $paymentMethods = $this->getPaymentMethods(); - if (is_null($paymentMethods)) { - return; - } - - $this->initForm(); - $fieldsForm = []; - $fieldsForm[0]['form'] = $this->fields_form; - - /** @var SaferPayObtainPaymentMethods $saferPayObtainPaymentMethods */ - $saferPayObtainPaymentMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); - - $paymentMethodsList = $saferPayObtainPaymentMethods->obtainPaymentMethods(); - - foreach ($paymentMethods as $paymentMethod) { - $isActive = $paymentRepository->isActiveByName($paymentMethod); - $isLogoActive = $logoRepository->isActiveByName($paymentMethod); - $isFieldActive = $fieldRepository->isActiveByName($paymentMethod); - $selectedCountries = $restrictionRepository->getSelectedIdsByName( - $paymentMethod, - SaferPayRestrictionCreator::RESTRICTION_COUNTRY - ); - $selectedCurrencies = $restrictionRepository->getSelectedIdsByName( - $paymentMethod, - SaferPayRestrictionCreator::RESTRICTION_CURRENCY - ); - - $this->context->smarty->assign( - [ - 'is_active' => $isActive, - 'is_logo_active' => $isLogoActive, - 'paymentMethod' => $paymentMethod, - 'countryOptions' => $this->getActiveCountriesList(), - 'countrySelect' => $selectedCountries, - 'currencyOptions' => $this->getActiveCurrenciesList($paymentMethod, $paymentMethodsList), - 'currencySelect' => $selectedCurrencies, - 'is_field_active' => $isFieldActive, - 'supported_field_payments' => SaferPayConfig::FIELD_SUPPORTED_PAYMENT_METHODS, - ] - ); - $referralOptionsForm->fields_value[$paymentMethod] = - $this->context->smarty->fetch( - $this->module->getLocalPath() . 'views/templates/admin/payment_method.tpl' - ); - } - $referralOptionsForm->fields_value['all'] = - $this->context->smarty->fetch( - $this->module->getLocalPath() . 'views/templates/admin/payment_method_all.tpl' - ); - $referralOptionsForm->fields_value['payment_method_label'] = - $this->context->smarty->fetch( - $this->module->getLocalPath() . 'views/templates/admin/payment_method_label.tpl' - ); - $this->content .= $referralOptionsForm->generateForm($fieldsForm); - } - - public function getActiveCountriesList($onlyActive = true) - { - $langId = $this->context->language->id; - $countries = Country::getCountries($langId, $onlyActive); - $countriesWithNames = []; - $countriesWithNames[0] = $this->module->l('All'); - foreach ($countries as $key => $country) { - $countriesWithNames[$key] = $country['name']; - } - - return $countriesWithNames; - } - - public function getActiveCurrenciesList($paymentMethod, $paymentMethods) + public function init() { - $currencyOptions[0] = $this->module->l('All'); - - if (!isset($paymentMethods[$paymentMethod]['currencies']) && in_array($paymentMethod, SaferPayConfig::WALLET_PAYMENT_METHODS)) { - foreach (Currency::getCurrencies() as $currency) { - $currencyOptions[$currency['id_currency']] = $currency['iso_code']; - } - - return $currencyOptions; - } - - foreach ($paymentMethods[$paymentMethod]['currencies'] as $currencyIso) { - if (Currency::getIdByIsoCode($currencyIso)) { - $currencyOptions[Currency::getIdByIsoCode($currencyIso)] = $currencyIso; - } - } - - return $currencyOptions; - } - - protected function initForm() - { - $fields = []; - $fields[] = [ - 'type' => 'free', - 'label' => '', - 'name' => 'payment_method_label', - ]; - $fields[] = [ - 'type' => 'free', - 'label' => $this->module->l('All payments'), - 'name' => 'all', - 'form_group_class' => 'saferpay-group all-payments', - ]; - - try { - /** @var SaferPayObtainPaymentMethods $saferPayObtainPaymentMethods */ - $saferPayObtainPaymentMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); - $paymentMethods = $saferPayObtainPaymentMethods->obtainPaymentMethodsNamesAsArray(); - } catch (SaferPayApiException $exception) { - /** @var SaferPayExceptionService $exceptionService */ - $exceptionService = $this->module->getService(SaferPayExceptionService::class); - $saferPayErrors = json_decode($this->context->cookie->saferPayErrors, true); - $saferPayErrors[] = $exceptionService->getErrorMessageForException( - $exception, - $exceptionService->getErrorMessages() - ); - $this->context->cookie->saferPayErrors = json_encode($saferPayErrors); - - $this->errors[] = $this->module->l('Please connect to SaferPay system to allowed payment methods.'); - - return; - } - /** @var SaferPayPaymentNotation $saferPayPaymentNotation */ - $saferPayPaymentNotation = $this->module->getService(SaferPayPaymentNotation::class); - - foreach ($paymentMethods as $paymentMethod) { - $fields[] = [ - 'type' => 'free', - 'label' => $saferPayPaymentNotation->getForDisplay($paymentMethod), - 'name' => $paymentMethod, - 'form_group_class' => 'saferpay-group', - ]; - } - - $this->fields_form = [ - 'legend' => [ - 'title' => $this->module->l('Payments'), - ], - 'input' => - $fields, - 'submit' => [ - 'title' => $this->module->l('Save'), - ], - ]; - } - - private function getPaymentMethods() - { - try { - /** @var SaferPayObtainPaymentMethods $saferPayObtainPaymentMethods */ - $saferPayObtainPaymentMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); - - return $saferPayObtainPaymentMethods->obtainPaymentMethodsNamesAsArray(); - } catch (SaferPayApiException $exception) { - /** @var SaferPayExceptionService $exceptionService */ - $exceptionService = $this->module->getService(SaferPayExceptionService::class); - $saferPayErrors = json_decode($this->context->cookie->saferPayErrors, true); - $saferPayErrors[] = $exceptionService->getErrorMessageForException( - $exception, - $exceptionService->getErrorMessages() - ); - $this->context->cookie->saferPayErrors = json_encode($saferPayErrors); - - $this->errors[] = $this->module->l('To see available payment methods, you must connect to your SaferPay account.'); - - return null; - } + parent::init(); + Tools::redirectAdmin($this->context->link->getAdminLink('AdminSaferPayOfficialSettings')); } } diff --git a/controllers/admin/AdminSaferPayOfficialSettingsController.php b/controllers/admin/AdminSaferPayOfficialSettingsController.php index 7adfd5731..2e952cc9e 100755 --- a/controllers/admin/AdminSaferPayOfficialSettingsController.php +++ b/controllers/admin/AdminSaferPayOfficialSettingsController.php @@ -22,8 +22,22 @@ */ use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\Repository\SaferPayFieldRepository; +use Invertus\SaferPay\Repository\SaferPayLogoRepository; +use Invertus\SaferPay\Repository\SaferPayPaymentRepository; +use Invertus\SaferPay\Repository\SaferPayRestrictionRepository; use Invertus\SaferPay\Repository\SaferPaySavedCreditCardRepository; -use Invertus\SaferPay\Adapter\Configuration; +use Invertus\SaferPay\Adapter\Configuration as SaferPayConfiguration; +use Invertus\SaferPay\Service\SaferPayFieldCreator; +use Invertus\SaferPay\Service\SaferPayGetTerminals; +use Invertus\SaferPay\Service\SaferPayLogoCreator; +use Invertus\SaferPay\Service\SaferPayObtainPaymentMethods; +use Invertus\SaferPay\Service\SaferPayPaymentCreator; +use Invertus\SaferPay\Service\SaferPayPaymentNotation; +use Invertus\SaferPay\Service\SaferPayRefreshPaymentsService; +use Invertus\SaferPay\Service\SaferPayRestrictionCreator; +use Invertus\SaferPay\Exception\Api\SaferPayApiException; +use Invertus\SaferPay\Exception\Restriction\RestrictionException; require_once dirname(__FILE__) . '/../../vendor/autoload.php'; @@ -34,6 +48,7 @@ class AdminSaferPayOfficialSettingsController extends ModuleAdminController { const FILE_NAME = 'AdminSaferPayOfficialSettingsController'; + const PASSWORD_PLACEHOLDER = '********'; /** @var \SaferPayOfficial */ public $module; @@ -42,493 +57,511 @@ public function __construct() { parent::__construct(); $this->bootstrap = true; + } + + public function setMedia($isNewTheme = false) + { + parent::setMedia($isNewTheme); - $this->tpl_folder = 'field-option-settings/'; - $this->initOptions(); + $distPath = 'modules/' . $this->module->name . '/views/js/admin/dist/'; + $this->addJS($distPath . 'saferpay-settings.js'); + $this->addCSS($distPath . 'saferpay-settings.css'); } public function initContent() { parent::initContent(); + + $settingsData = $this->collectSettingsData(); + + $this->context->smarty->assign([ + 'settingsDataJson' => json_encode($settingsData), + ]); + + $this->content .= $this->context->smarty->fetch( + $this->module->getLocalPath() . 'views/templates/admin/settings_react.tpl' + ); + $this->context->smarty->assign('content', $this->content); } public function postProcess() { - parent::postProcess(); + if (!$this->isAjax()) { + return parent::postProcess(); + } + + $action = Tools::getValue('action'); + if ($action) { + $methodName = 'ajaxProcess' . ucfirst($action); + if (method_exists($this, $methodName)) { + $this->{$methodName}(); + } + } + } + + /** + * Check if current request is AJAX + */ + private function isAjax() + { + return Tools::getValue('ajax') == 1; + } + + /** + * AJAX: Save API credentials + */ + public function ajaxProcessSaveCredentials() + { + $data = $this->getJsonInput(); + if (!$data) { + $this->ajaxResponse(false, 'Invalid request data'); + return; + } + + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); + + // Test mode + $configuration->set(SaferPayConfig::TEST_MODE, !empty($data['testMode']) ? 1 : 0); + + // Test credentials + $configuration->set(SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX, $this->getStringValue($data, '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::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')); + $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 + $configuration->set(SaferPayConfig::USERNAME, $this->getStringValue($data, '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::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')); + $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); + + 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.'); + return; + } + + $this->ajaxResponse(true, 'API Credentials saved successfully'); + } + + /** + * AJAX: Save payment processing settings + */ + public function ajaxProcessSavePaymentProcessing() + { + $data = $this->getJsonInput(); + if (!$data) { + $this->ajaxResponse(false, 'Invalid request data'); + return; + } - /** @var Configuration $configuration */ - $configuration = $this->module->getService(Configuration::class); + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); - $isCreditCardSaveEnabled = $configuration->get(SaferPayConfig::CREDIT_CARD_SAVE); + $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::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')); - if (!$isCreditCardSaveEnabled) { + // If credit card save disabled, clean up saved cards + if (empty($data['creditCardSave']) || (int) $data['creditCardSave'] === 0) { /** @var SaferPaySavedCreditCardRepository $cardRepo */ $cardRepo = $this->module->getService(SaferPaySavedCreditCardRepository::class); $cardRepo->deleteAllSavedCreditCards(); } - $haveFieldToken = $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::getConfigSuffix()); - $haveBusinessLicense = $configuration->get(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix()); + $this->ajaxResponse(true, 'Payment Processing saved successfully'); + } - if (!$haveFieldToken && $haveBusinessLicense) { - $configuration->set(SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::getConfigSuffix(), 0); - $this->errors[] = $this->module->l('Field Access Token is required to use business license'); + /** + * AJAX: Save email settings + */ + public function ajaxProcessSaveEmailSettings() + { + $data = $this->getJsonInput(); + if (!$data) { + $this->ajaxResponse(false, 'Invalid request data'); + return; } - return true; + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); + + $configuration->set(SaferPayConfig::SAFERPAY_ALLOW_SAFERPAY_SEND_CUSTOMER_MAIL, !empty($data['allowSaferpayMail']) ? 1 : 0); + $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'); } - public function initOptions() + /** + * AJAX: Save general settings + */ + public function ajaxProcessSaveGeneralSettings() { - $this->context->smarty->assign(SaferPayConfig::PASSWORD, SaferPayConfig::WEB_SERVICE_PASSWORD_PLACEHOLDER); - - $this->fields_options[] = $this->displayEnvironmentSelectorConfiguration(); - $this->fields_options[] = $this->displayLiveEnvironmentConfiguration(); - $this->fields_options[] = $this->displayTestEnvironmentConfiguration(); - $this->fields_options[] = $this->displayPaymentBehaviorConfiguration(); - $this->fields_options[] = $this->displayStylingConfiguration(); - $this->fields_options[] = $this->displaySavedCardsConfiguration(); - $this->fields_options[] = $this->displayEmailSettings(); - $this->fields_options[] = $this->getFieldOptionsOrderState(); - $this->fields_options[] = $this->displayConfigurationSettings(); + $data = $this->getJsonInput(); + if (!$data) { + $this->ajaxResponse(false, 'Invalid request data'); + 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_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'); } /** - * @param $isNewTheme - * @return void + * AJAX: Save payment methods */ - public function setMedia($isNewTheme = false) + public function ajaxProcessSavePaymentMethods() { - parent::setMedia($isNewTheme); + $data = $this->getJsonInput(); + if (!$data || !isset($data['paymentMethods'])) { + $this->ajaxResponse(false, 'Invalid request data'); + return; + } + + // Refresh payments first + /** @var SaferPayRefreshPaymentsService $refreshPaymentsService */ + $refreshPaymentsService = $this->module->getService(SaferPayRefreshPaymentsService::class); + try { + $refreshPaymentsService->refreshPayments(); + } catch (SaferPayApiException $exception) { + $this->ajaxResponse(false, $exception->getMessage()); + return; + } + + /** @var SaferPayPaymentCreator $paymentCreation */ + $paymentCreation = $this->module->getService(SaferPayPaymentCreator::class); - $this->addJS('modules/' . $this->module->name . '/views/js/admin/saferpay_settings.js'); + /** @var SaferPayLogoCreator $logoCreation */ + $logoCreation = $this->module->getService(SaferPayLogoCreator::class); + + /** @var SaferPayFieldCreator $fieldCreation */ + $fieldCreation = $this->module->getService(SaferPayFieldCreator::class); + + /** @var SaferPayRestrictionCreator $restrictionCreator */ + $restrictionCreator = $this->module->getService(SaferPayRestrictionCreator::class); + + $success = true; + foreach ($data['paymentMethods'] as $method) { + $paymentName = $method['name']; + $success &= $paymentCreation->updatePayment($paymentName, !empty($method['enabled'])); + $success &= $logoCreation->updateLogo($paymentName, !empty($method['showLogos'])); + $success &= $fieldCreation->updateField($paymentName, !empty($method['showCustomForm'])); + + try { + $countries = isset($method['countries']) ? $method['countries'] : []; + $currencies = isset($method['currencies']) ? $method['currencies'] : []; + + $success &= $restrictionCreator->updateRestriction( + $paymentName, + SaferPayRestrictionCreator::RESTRICTION_COUNTRY, + $countries + ); + $success &= $restrictionCreator->updateRestriction( + $paymentName, + SaferPayRestrictionCreator::RESTRICTION_CURRENCY, + $currencies + ); + } catch (RestrictionException $e) { + $this->ajaxResponse(false, 'Wrong restriction type'); + return; + } + } + + if (!$success) { + $this->ajaxResponse(false, 'Failed to update payment methods'); + return; + } + + $this->ajaxResponse(true, 'Payment methods saved successfully'); } /** - * @return array + * AJAX: Get terminals */ - private function getFieldOptionsOrderState() + public function ajaxProcessGetTerminals() { - return [ - 'title' => $this->module->l('Order state'), - 'fields' => [ - SaferPayConfig::SAFERPAY_ORDER_STATE_CHOICE_AWAITING_PAYMENT => [ - 'title' => $this->module->l( - sprintf( - 'Status for %s', - Tools::ucfirst(Tools::strtolower(SaferPayConfig::SAFERPAY_PAYMENT_AWAITING)) - ) - ), - 'required' => false, - 'cast' => 'intval', - 'type' => 'select', - 'list' => OrderState::getOrderStates($this->context->language->id), - 'identifier' => 'id_order_state', - 'desc' => 'Default status on SaferPay order creation', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $data = $this->getJsonInput(); + if (!$data) { + $this->ajaxResponse(false, 'Invalid request data'); + return; + } + + $username = isset($data['username']) ? $data['username'] : ''; + $password = isset($data['password']) ? $data['password'] : ''; + $customerId = isset($data['customerId']) ? $data['customerId'] : ''; + $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, 'Username, password and customer ID are required'); + return; + } + + try { + /** @var SaferPayGetTerminals $getTerminals */ + $getTerminals = $this->module->getService(SaferPayGetTerminals::class); + $terminals = $getTerminals->fetchTerminalsWithCredentials($username, $password, $customerId, $isTestMode); + + $this->ajaxDie(json_encode([ + 'success' => true, + 'terminals' => $terminals, + ])); + } catch (\Exception $e) { + $this->ajaxResponse(false, 'Failed to fetch terminals: ' . $e->getMessage()); + } } /** - * @return array + * AJAX: Refresh all data */ - private function displayConfigurationSettings() + public function ajaxProcessRefreshData() { - return [ - 'title' => $this->module->l('Configuration', self::FILE_NAME), - 'fields' => [ - SaferPayConfig::SAFERPAY_PAYMENT_DESCRIPTION => [ - 'title' => $this->module->l('Description', self::FILE_NAME), - 'type' => 'text', - 'desc' => 'This description is visible in payment page also in payment confirmation email', - 'class' => 'fixed-width-xxl', - ], - SaferPayConfig::SAFERPAY_DEBUG_MODE => [ - 'title' => $this->module->l('Debug mode', self::FILE_NAME), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - 'desc' => $this->module->l('Enable debug mode to see more information in logs', self::FILE_NAME), - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save', self::FILE_NAME), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $settingsData = $this->collectSettingsData(); + $this->ajaxDie(json_encode([ + 'success' => true, + 'data' => $settingsData, + ])); } /** - * @return array + * Collect all settings data to pass to the React app */ - private function displaySavedCardsConfiguration() + private function collectSettingsData() { - return [ - 'title' => $this->module->l('Credit card saving'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::CREDIT_CARD_SAVE => [ - 'type' => 'radio', - 'title' => $this->module->l('Credit card saving for customers'), - 'validation' => 'isInt', - 'choices' => [ - 1 => $this->module->l('Enable'), - 0 => $this->module->l('Disable'), - ], - 'desc' => $this->module->l('Allow customers to save credit card for faster purchase'), - 'form_group_class' => 'thumbs_chose', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], + /** @var SaferPayConfiguration $configuration */ + $configuration = $this->module->getService(SaferPayConfiguration::class); + + $data = [ + // Environment + 'testMode' => (bool) $configuration->get(SaferPayConfig::TEST_MODE), + + // 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), + '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), + '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), + 'liveFieldAccessToken' => (string) $configuration->get(SaferPayConfig::FIELDS_ACCESS_TOKEN), + 'liveFieldJsUrl' => (string) $configuration->get(SaferPayConfig::FIELDS_LIBRARY), + 'liveBusinessLicense' => (bool) $configuration->get(SaferPayConfig::BUSINESS_LICENSE), + + // Payment Processing + 'paymentBehavior' => (int) $configuration->get(SaferPayConfig::PAYMENT_BEHAVIOR), + 'paymentBehaviorWithout3D' => (int) $configuration->get(SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D), + 'restrictRefund' => (int) $configuration->get(SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT), + 'orderCreationAfterAuth' => (int) $configuration->get(SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION), + 'groupCards' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_GROUP_CARDS), + 'groupCardsLogo' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_GROUP_CARDS_LOGO), + 'creditCardSave' => (int) $configuration->get(SaferPayConfig::CREDIT_CARD_SAVE), + + // Email + 'allowSaferpayMail' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_ALLOW_SAFERPAY_SEND_CUSTOMER_MAIL), + 'sendNewOrderMail' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_SEND_NEW_ORDER_MAIL), + 'sendOrderConfMail' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_SEND_ORDER_CONF_MAIL), + + // General + '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), + 'debugMode' => (bool) $configuration->get(SaferPayConfig::SAFERPAY_DEBUG_MODE), + + // Reference data + 'orderStates' => $this->getOrderStates(), + 'countries' => $this->getCountries(), + 'currencies' => $this->getCurrencies(), + 'paymentMethods' => $this->getPaymentMethodsData(), + + // Endpoints + 'ajaxUrl' => $this->context->link->getAdminLink('AdminSaferPayOfficialSettings'), + 'adminToken' => Tools::getAdminTokenLite('AdminSaferPayOfficialSettings'), ]; + + return $data; } /** - * @return array + * Get order states for dropdown */ - private function displayStylingConfiguration() + private function getOrderStates() { - return [ - 'title' => $this->module->l('Styling'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::CONFIGURATION_NAME => [ - 'title' => $this->module->l('Payment Page configurations name'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $states = OrderState::getOrderStates($this->context->language->id); + $result = []; + foreach ($states as $state) { + $result[] = [ + 'id' => (int) $state['id_order_state'], + 'name' => $state['name'], + ]; + } + return $result; } /** - * @return array + * Get active countries */ - private function displayEmailSettings() + private function getCountries() { - return [ - 'title' => $this->module->l('Email sending'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::SAFERPAY_ALLOW_SAFERPAY_SEND_CUSTOMER_MAIL => [ - 'title' => $this->module->l('Send an email from Saferpay on payment completion'), - 'desc' => $this->module->l('With this setting enabled an email from the Saferpay system will be sent to the customer'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - SaferPayConfig::SAFERPAY_SEND_NEW_ORDER_MAIL => [ - 'title' => $this->module->l('Send new order mail on authorization'), - 'desc' => $this->module->l('Receive a notification when an order is authorized by Saferpay (Using the Mail alert module)'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - SaferPayConfig::SAFERPAY_SEND_ORDER_CONF_MAIL => [ - 'title' => $this->module->l('Send order confirmation mail on payment completion'), - 'desc' => $this->module->l('Send an email from Saferpay on payment completion'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - SaferPayConfig::SAFERPAY_SEND_NEW_ORDER_MAIL . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-new-order-mail-desc.tpl', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $countries = Country::getCountries($this->context->language->id, true); + $result = []; + $result[] = ['id' => 0, 'name' => 'All']; + foreach ($countries as $key => $country) { + $result[] = [ + 'id' => (int) $key, + 'name' => $country['name'], + ]; + } + return $result; } /** - * @return array + * Get active currencies */ - private function displayPaymentBehaviorConfiguration() + private function getCurrencies() { - return [ - 'title' => $this->module->l('Payment behavior'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::PAYMENT_BEHAVIOR => [ - 'type' => 'radio', - 'title' => $this->module->l('Default payment behavior'), - 'validation' => 'isInt', - 'choices' => [ - 0 => $this->module->l('Capture'), - 1 => $this->module->l('Authorize'), - ], - 'desc' => $this->module->l('How payment provider should behave when order is created'), - 'form_group_class' => 'thumbs_chose', - ], - SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D => [ - 'type' => 'radio', - 'title' => $this->module->l('Behaviour when 3D secure fails'), - 'validation' => 'isInt', - 'choices' => [ - SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_CANCEL => $this->module->l('Cancel'), - SaferPayConfig::PAYMENT_BEHAVIOR_WITHOUT_3D_AUTHORIZE => $this->module->l('Authorize'), - ], - 'desc' => $this->module->l('Default payment behavior for payment without 3-D Secure'), - 'form_group_class' => 'thumbs_chose', - ], - SaferPayConfig::RESTRICT_REFUND_AMOUNT_TO_CAPTURED_AMOUNT => [ - 'type' => 'radio', - 'title' => $this->module->l('Restrict RefundAmount To Captured Amount'), - 'validation' => 'isInt', - 'choices' => [ - 1 => $this->module->l('Enable'), - 0 => $this->module->l('Disable'), - ], - 'desc' => $this->module->l('If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value.'), - 'form_group_class' => 'thumbs_chose', - ], - SaferPayConfig::SAFERPAY_ORDER_CREATION_AFTER_AUTHORIZATION => [ - 'type' => 'radio', - 'title' => $this->module->l('Order creation rule'), - 'validation' => 'isInt', - 'choices' => [ - 1 => $this->module->l('After authorization'), - 0 => $this->module->l('Before authorization'), - ], - 'desc' => $this->module->l('Select the option to determine whether the order should be created'), - 'form_group_class' => 'thumbs_chose', - ], - SaferPayConfig::SAFERPAY_GROUP_CARDS => [ - 'type' => 'bool', - 'title' => $this->module->l("Group debit/credit cards as 'Cards' in checkout", self::FILE_NAME), - 'validation' => 'isBool', - 'cast' => 'intval', - 'desc' => $this->module->l("If enabled, all supported card brands (Visa, Mastercard, Amex, etc.) will be grouped and shown as a single 'Cards' payment method at checkout.", self::FILE_NAME), - ], - SaferPayConfig::SAFERPAY_GROUP_CARDS_LOGO => [ - 'type' => 'bool', - 'title' => $this->module->l("Show 'Cards' payment method logo", self::FILE_NAME), - 'validation' => 'isBool', - 'cast' => 'intval', - 'desc' => $this->module->l("If enabled, a logo for the grouped 'Cards' payment method will be displayed at checkout.", self::FILE_NAME), - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $currencies = Currency::getCurrencies(); + $result = []; + $result[] = ['id' => 0, 'iso_code' => 'All']; + foreach ($currencies as $currency) { + $result[] = [ + 'id' => (int) $currency['id_currency'], + 'iso_code' => $currency['iso_code'], + ]; + } + return $result; } /** - * @return array + * Get payment methods data with their current state */ - private function displayTestEnvironmentConfiguration() + private function getPaymentMethodsData() { - return [ - 'title' => $this->module->l('Test environment'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::USERNAME . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('JSON API Username'), - 'type' => 'text', - 'validation' => 'isGenericName', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('JSON API Password'), - 'type' => 'password_input', - 'class' => 'fixed-width-xl', - 'value' => \Configuration::get(SaferPayConfig::PASSWORD . SaferPayConfig::TEST_SUFFIX), - ], - SaferPayConfig::CUSTOMER_ID . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('Customer ID'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - 'size' => 3, - ], - SaferPayConfig::TERMINAL_ID . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('Terminal ID'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::MERCHANT_EMAILS . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('Merchant emails'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-access-token-desc.tpl', - ], - SaferPayConfig::FIELDS_ACCESS_TOKEN . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('Field Access Token'), - 'type' => 'text', - 'class' => 'fixed-width-xxl', - ], - SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-javascript-library-desc.tpl', - ], - SaferPayConfig::FIELDS_LIBRARY . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('Field Javascript library url'), - 'type' => 'text', - 'class' => 'fixed-width-xxl', - ], - SaferPayConfig::BUSINESS_LICENSE . SaferPayConfig::TEST_SUFFIX => [ - 'title' => $this->module->l('I have Business license'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + try { + /** @var SaferPayObtainPaymentMethods $obtainMethods */ + $obtainMethods = $this->module->getService(SaferPayObtainPaymentMethods::class); + $paymentMethods = $obtainMethods->obtainPaymentMethodsNamesAsArray(); + } catch (SaferPayApiException $exception) { + return []; + } + + /** @var SaferPayPaymentRepository $paymentRepository */ + $paymentRepository = $this->module->getService(SaferPayPaymentRepository::class); + + /** @var SaferPayLogoRepository $logoRepository */ + $logoRepository = $this->module->getService(SaferPayLogoRepository::class); + + /** @var SaferPayFieldRepository $fieldRepository */ + $fieldRepository = $this->module->getService(SaferPayFieldRepository::class); + + /** @var SaferPayRestrictionRepository $restrictionRepository */ + $restrictionRepository = $this->module->getService(SaferPayRestrictionRepository::class); + + /** @var SaferPayPaymentNotation $saferPayPaymentNotation */ + $saferPayPaymentNotation = $this->module->getService(SaferPayPaymentNotation::class); + + $result = []; + foreach ($paymentMethods as $paymentMethod) { + $result[] = [ + 'name' => $paymentMethod, + 'displayName' => $saferPayPaymentNotation->getForDisplay($paymentMethod), + 'enabled' => (bool) $paymentRepository->isActiveByName($paymentMethod), + 'showLogos' => (bool) $logoRepository->isActiveByName($paymentMethod), + 'showCustomForm' => (bool) $fieldRepository->isActiveByName($paymentMethod), + 'hasCustomForm' => in_array($paymentMethod, SaferPayConfig::FIELD_SUPPORTED_PAYMENT_METHODS), + 'countries' => $restrictionRepository->getSelectedIdsByName( + $paymentMethod, + SaferPayRestrictionCreator::RESTRICTION_COUNTRY + ), + 'currencies' => $restrictionRepository->getSelectedIdsByName( + $paymentMethod, + SaferPayRestrictionCreator::RESTRICTION_CURRENCY + ), + ]; + } + + return $result; } /** - * @return array + * Get JSON input from request body */ - private function displayLiveEnvironmentConfiguration() + private function getJsonInput() { - return [ - 'title' => $this->module->l('Live environment'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::USERNAME => [ - 'title' => $this->module->l('JSON API Username'), - 'type' => 'text', - 'validation' => 'isGenericName', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::PASSWORD => [ - 'title' => $this->module->l('JSON API Password'), - 'type' => 'password_input', - 'class' => 'fixed-width-xl', - 'value' => \Configuration::get(SaferPayConfig::PASSWORD), - ], - SaferPayConfig::CUSTOMER_ID => [ - 'title' => $this->module->l('Customer ID'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - 'size' => 3, - ], - SaferPayConfig::TERMINAL_ID => [ - 'title' => $this->module->l('Terminal ID'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::MERCHANT_EMAILS => [ - 'title' => $this->module->l('Merchant emails'), - 'type' => 'text', - 'class' => 'fixed-width-xl', - ], - SaferPayConfig::FIELDS_ACCESS_TOKEN . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-access-token-desc.tpl', - ], - SaferPayConfig::FIELDS_ACCESS_TOKEN => [ - 'title' => $this->module->l('Field Access Token'), - 'type' => 'text', - 'class' => 'fixed-width-xxl', - ], - SaferPayConfig::FIELDS_LIBRARY . '_description' => [ - 'type' => 'desc', - 'class' => 'col-lg-12', - 'template' => 'field-javascript-library-desc.tpl', - ], - SaferPayConfig::FIELDS_LIBRARY => [ - 'title' => $this->module->l('Field Javascript library url'), - 'type' => 'text', - 'class' => 'fixed-width-xxl', - ], - SaferPayConfig::BUSINESS_LICENSE => [ - 'title' => $this->module->l('I have Business license'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + return is_array($data) ? $data : null; } /** - * @return array + * Send AJAX JSON response */ - private function displayEnvironmentSelectorConfiguration() + private function ajaxResponse($success, $message = '') { - return [ - 'title' => $this->module->l('Select environment'), - 'icon' => 'icon-settings', - 'fields' => [ - SaferPayConfig::TEST_MODE => [ - 'title' => $this->module->l('Test mode'), - 'validation' => 'isBool', - 'cast' => 'intval', - 'type' => 'bool', - ], - ], - 'buttons' => [ - 'save_and_connect' => [ - 'title' => $this->module->l('Save'), - 'icon' => 'process-icon-save', - 'class' => 'btn btn-default pull-right', - 'type' => 'submit', - ], - ], - ]; + $this->ajaxDie(json_encode([ + 'success' => $success, + 'message' => $message, + ])); + } + + /** + * Get string value from data array + */ + private function getStringValue($data, $key) + { + return isset($data[$key]) ? pSQL((string) $data[$key]) : ''; + } + + /** + * Get int value from data array + */ + private function getIntValue($data, $key) + { + return isset($data[$key]) ? (int) $data[$key] : 0; } } diff --git a/force_https.sql b/force_https.sql new file mode 100644 index 000000000..3f54a7e48 --- /dev/null +++ b/force_https.sql @@ -0,0 +1,11 @@ +-- Force PrestaShop to use HTTPS/SSL +-- Run against your PrestaShop database + +-- Enable SSL +UPDATE ps_configuration SET value = '1' WHERE name = 'PS_SSL_ENABLED'; + +-- Enable SSL everywhere (force all pages to HTTPS) +UPDATE ps_configuration SET value = '1' WHERE name = 'PS_SSL_ENABLED_EVERYWHERE'; + +-- Copy domain to domain_ssl if domain_ssl is empty or different +UPDATE ps_shop_url SET domain_ssl = domain WHERE domain_ssl = '' OR domain_ssl IS NULL; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..cb24cfd8f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1367 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + cypress: + specifier: ^9.6.1 + version: 9.7.0 + cypress-iframe: + specifier: ^1.0.1 + version: 1.0.1(@types/cypress@1.1.6) + +packages: + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@cypress/request@2.88.12': + resolution: {integrity: sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==} + engines: {node: '>= 6'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + + '@types/cypress@1.1.6': + resolution: {integrity: sha512-CfeLLD3+6vIWe2AO5hR63f1c8EbRzrp/j1ExubAwOTpwZFZvF3Nm9cOPQiUwzNmAUmZuhO0QVH98Qlujni6nPw==} + deprecated: This is a stub types definition. cypress provides its own type definitions, so you do not need this installed. + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + + '@types/sizzle@2.3.10': + resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-more-types@2.24.0: + resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} + engines: {node: '>= 0.8.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cypress-iframe@1.0.1: + resolution: {integrity: sha512-Ne+xkZmWMhfq3x6wbfzK/SzsVTCrJru3R3cLXsoSAZyfUtJDamXyaIieHXeea3pQDXF4wE2w4iUuvCYHhoD31g==} + peerDependencies: + '@types/cypress': ^1.1.0 + + cypress@9.7.0: + resolution: {integrity: sha512-+1EE1nuuuwIt/N1KXRR2iWHU+OiIt7H28jJDyyI4tiUftId/DrXYEwoDa5+kH2pki1zxnA0r6HrUGHV5eLbF5Q==} + engines: {node: '>=12.0.0'} + hasBin: true + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + getos@3.2.1: + resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-signature@1.3.6: + resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==} + engines: {node: '>=0.10'} + + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + + lazy-ass@1.6.0: + resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} + engines: {node: '> 0.8'} + + listr2@3.14.0: + resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.10.4: + resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==} + engines: {node: '>=0.6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + +snapshots: + + '@colors/colors@1.5.0': + optional: true + + '@cypress/request@2.88.12': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + http-signature: 1.3.6 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.10.4 + safe-buffer: 5.2.1 + tough-cookie: 4.1.4 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + + '@types/cypress@1.1.6': + dependencies: + cypress: 9.7.0 + + '@types/node@14.18.63': {} + + '@types/sinonjs__fake-timers@8.1.1': {} + + '@types/sizzle@2.3.10': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 14.18.63 + optional: true + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + arch@2.2.0: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + astral-regex@2.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + blob-util@2.0.2: {} + + bluebird@3.7.2: {} + + buffer-crc32@0.2.13: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cachedir@2.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caseless@0.12.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-more-types@2.24.0: {} + + ci-info@3.9.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.1.0: {} + + common-tags@1.8.2: {} + + core-util-is@1.0.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cypress-iframe@1.0.1(@types/cypress@1.1.6): + dependencies: + '@types/cypress': 1.1.6 + + cypress@9.7.0: + dependencies: + '@cypress/request': 2.88.12 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/node': 14.18.63 + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.10 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.5 + commander: 5.1.0 + common-tags: 1.8.2 + dayjs: 1.11.19 + debug: 4.4.3(supports-color@8.1.1) + enquirer: 2.4.1 + eventemitter2: 6.4.9 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-ci: 3.0.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.4.1) + lodash: 4.17.23 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.7.4 + supports-color: 8.1.1 + tmp: 0.2.5 + untildify: 4.0.0 + yauzl: 2.10.0 + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + dayjs@1.11.19: {} + + debug@3.2.7(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-string-regexp@1.0.5: {} + + eventemitter2@6.4.9: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + executable@4.1.1: + dependencies: + pify: 2.3.0 + + extend@3.0.2: {} + + extract-zip@2.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.3.0: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + forever-agent@0.6.1: {} + + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + getos@3.2.1: + dependencies: + async: 3.2.6 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-signature@1.3.6: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + + human-signals@1.1.1: {} + + ieee754@1.2.1: {} + + indent-string@4.0.0: {} + + ini@2.0.0: {} + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-fullwidth-code-point@3.0.0: {} + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-path-inside@3.0.3: {} + + is-stream@2.0.1: {} + + is-typedarray@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + isstream@0.1.2: {} + + jsbn@0.1.1: {} + + json-schema@0.4.0: {} + + json-stringify-safe@5.0.1: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + lazy-ass@1.6.0: {} + + listr2@3.14.0(enquirer@2.4.1): + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.4.1 + rxjs: 7.8.2 + through: 2.3.8 + wrap-ansi: 7.0.0 + optionalDependencies: + enquirer: 2.4.1 + + lodash.once@4.1.1: {} + + lodash@4.17.23: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@4.0.0: + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + + math-intrinsics@1.1.0: {} + + merge-stream@2.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + minimist@1.2.8: {} + + ms@2.1.3: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-inspect@1.13.4: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + ospath@1.2.2: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + path-key@3.1.1: {} + + pend@1.2.0: {} + + performance-now@2.1.0: {} + + pify@2.3.0: {} + + pretty-bytes@5.6.0: {} + + proxy-from-env@1.0.0: {} + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + qs@6.10.4: + dependencies: + side-channel: 1.1.0 + + querystringify@2.2.0: {} + + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + + requires-port@1.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rfdc@1.4.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-final-newline@2.0.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + throttleit@1.0.1: {} + + through@2.3.8: {} + + tmp@0.2.5: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + + type-fest@0.21.3: {} + + universalify@0.2.0: {} + + universalify@2.0.1: {} + + untildify@4.0.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + uuid@8.3.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 diff --git a/src/Api/ApiRequest.php b/src/Api/ApiRequest.php index 512e75427..70c9323a1 100755 --- a/src/Api/ApiRequest.php +++ b/src/Api/ApiRequest.php @@ -127,6 +127,60 @@ public function get($url, $params = []) } } + /** + * API Request Get Method with explicit credentials. + * + * @param string $url + * @param string $username + * @param string $password + * @param string $baseUrl + * @param array $params + * @return mixed + * @throws Exception + */ + public function getWithCredentials($url, $username, $password, $baseUrl, $params = []) + { + $response = null; + + try { + $credentials = base64_encode("$username:$password"); + $headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Saferpay-ApiVersion' => SaferPayConfig::API_VERSION, + 'Saferpay-RequestId' => 'false', + 'Authorization' => "Basic $credentials", + ]; + + $response = Request::get( + $baseUrl . $url, + $headers, + $params + ); + + $this->logger->debug(sprintf('%s - GET (credentials) response: %d', self::FILE_NAME, $response->code), [ + 'context' => [ + 'uri' => $baseUrl . $url, + ], + 'request' => $params, + 'response' => $response->body, + ]); + + $this->isValidResponse($response); + + return json_decode($response->raw_body); + } catch (Exception $exception) { + $this->logger->error($exception->getMessage(), [ + 'context' => [], + 'request' => $params, + 'response' => $response ? json_decode($response->raw_body) : null, + 'exceptions' => ExceptionUtility::getExceptions($exception), + ]); + + throw $exception; + } + } + private function getHeaders() { $username = Configuration::get(SaferPayConfig::USERNAME . SaferPayConfig::getConfigSuffix()); diff --git a/src/Api/Request/GetTerminalsService.php b/src/Api/Request/GetTerminalsService.php new file mode 100644 index 000000000..6ca24b6b3 --- /dev/null +++ b/src/Api/Request/GetTerminalsService.php @@ -0,0 +1,59 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Api\Request; + +use Invertus\SaferPay\Api\ApiRequest; +use Invertus\SaferPay\DTO\Request\GetTerminals\GetTerminalsRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GetTerminalsService +{ + /** @var ApiRequest */ + private $apiRequest; + + public function __construct(ApiRequest $apiRequest) + { + $this->apiRequest = $apiRequest; + } + + /** + * @param GetTerminalsRequest $request + * @param string $username + * @param string $password + * @param string $baseUrl + * @return mixed + */ + public function getTerminals(GetTerminalsRequest $request, $username, $password, $baseUrl) + { + return $this->apiRequest->getWithCredentials( + $request->generateRequestUrl(), + $username, + $password, + $baseUrl + ); + } +} diff --git a/src/DTO/Request/GetTerminals/GetTerminalsRequest.php b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php new file mode 100644 index 000000000..2d3b6b3f3 --- /dev/null +++ b/src/DTO/Request/GetTerminals/GetTerminalsRequest.php @@ -0,0 +1,58 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\DTO\Request\GetTerminals; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class GetTerminalsRequest +{ + /** @var string */ + private $customerId; + + /** + * @param string $customerId + */ + public function __construct($customerId) + { + $this->customerId = $customerId; + } + + /** + * @return string + */ + public function getCustomerId() + { + return $this->customerId; + } + + /** + * @return string + */ + public function generateRequestUrl() + { + return sprintf('rest/customers/%s/terminals', $this->customerId); + } +} diff --git a/src/Install/AbstractInstaller.php b/src/Install/AbstractInstaller.php index 66c2fbb69..cbe8d897d 100755 --- a/src/Install/AbstractInstaller.php +++ b/src/Install/AbstractInstaller.php @@ -61,6 +61,7 @@ public function tabs() 'class_name' => SaferPayOfficial::ADMIN_PAYMENTS_CONTROLLER, 'parent_class_name' => SaferPayOfficial::ADMIN_SAFERPAY_MODULE_CONTROLLER, 'module_tab' => true, + 'visible' => false, ], [ 'name' => $this->module->l('Fields'), diff --git a/src/Service/SaferPayGetTerminals.php b/src/Service/SaferPayGetTerminals.php new file mode 100644 index 000000000..3c41d05d7 --- /dev/null +++ b/src/Service/SaferPayGetTerminals.php @@ -0,0 +1,80 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +namespace Invertus\SaferPay\Service; + +use Invertus\SaferPay\Api\Request\GetTerminalsService; +use Invertus\SaferPay\Config\SaferPayConfig; +use Invertus\SaferPay\DTO\Request\GetTerminals\GetTerminalsRequest; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SaferPayGetTerminals +{ + /** @var GetTerminalsService */ + private $getTerminalsService; + + public function __construct(GetTerminalsService $getTerminalsService) + { + $this->getTerminalsService = $getTerminalsService; + } + + /** + * @param string $username + * @param string $password + * @param string $customerId + * @param bool $isTestMode + * @return array + */ + public function fetchTerminalsWithCredentials($username, $password, $customerId, $isTestMode) + { + $baseUrl = $isTestMode ? SaferPayConfig::TEST_API : SaferPayConfig::API; + $request = new GetTerminalsRequest($customerId); + + $response = $this->getTerminalsService->getTerminals( + $request, + $username, + $password, + $baseUrl + ); + + $terminals = []; + $terminalList = isset($response->Terminals) ? $response->Terminals : []; + if (is_array($terminalList)) { + foreach ($terminalList as $terminal) { + if (isset($terminal->TerminalId)) { + $terminals[] = [ + 'id' => $terminal->TerminalId, + 'name' => isset($terminal->Description) + ? $terminal->Description . ' (' . $terminal->TerminalId . ')' + : $terminal->TerminalId, + ]; + } + } + } + + return $terminals; + } +} diff --git a/upgrade/install-2.1.0.php b/upgrade/install-2.1.0.php new file mode 100644 index 000000000..58ae7e34e --- /dev/null +++ b/upgrade/install-2.1.0.php @@ -0,0 +1,37 @@ + + *@copyright SIX Payment Services + *@license SIX Payment Services + */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +function upgrade_module_2_1_0() +{ + $tabId = Tab::getIdFromClassName('AdminSaferPayOfficialPayment'); + if ($tabId) { + $tab = new Tab($tabId); + $tab->delete(); + } + + return true; +} diff --git a/views/js/admin/settings-app/index.html b/views/js/admin/settings-app/index.html new file mode 100644 index 000000000..548a9d367 --- /dev/null +++ b/views/js/admin/settings-app/index.html @@ -0,0 +1,34 @@ + + + + + + SaferPay Settings (Dev) + + +
+ + + + diff --git a/views/js/admin/settings-app/package.json b/views/js/admin/settings-app/package.json new file mode 100644 index 000000000..0498a6630 --- /dev/null +++ b/views/js/admin/settings-app/package.json @@ -0,0 +1,39 @@ +{ + "name": "saferpay-settings-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "lucide-react": "^0.453.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "vite": "^5.4.10", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "tailwindcss": "^3.4.14", + "postcss": "^8.4.47", + "autoprefixer": "^10.4.20", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/views/js/admin/settings-app/pnpm-lock.yaml b/views/js/admin/settings-app/pnpm-lock.yaml new file mode 100644 index 000000000..48c426079 --- /dev/null +++ b/views/js/admin/settings-app/pnpm-lock.yaml @@ -0,0 +1,2597 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-checkbox': + specifier: ^1.1.2 + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.2 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.2.1 + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.1.2 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.1.0 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.2.4(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.1.1 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.1 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tailwind-merge: + specifier: ^2.5.4 + version: 2.6.1 + devDependencies: + '@types/react': + specifier: ^18.3.11 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.7.0(vite@5.4.21) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.24(postcss@8.5.6) + postcss: + specifier: ^8.4.47 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.14 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^5.4.10 + version: 5.4.21 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + autoprefixer@10.4.24: + resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.453.0: + resolution: {integrity: sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.10': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21 + transitivePeerDependencies: + - supports-color + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + autoprefixer@10.4.24(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001769 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.9.19: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001769: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + electron-to-chromium@1.5.286: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.453.0(react@18.3.1): + dependencies: + react: 18.3.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + + react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + util-deprecate@1.0.2: {} + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/views/js/admin/settings-app/postcss.config.mjs b/views/js/admin/settings-app/postcss.config.mjs new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/views/js/admin/settings-app/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/views/js/admin/settings-app/src/App.tsx b/views/js/admin/settings-app/src/App.tsx new file mode 100644 index 000000000..b2edbdd38 --- /dev/null +++ b/views/js/admin/settings-app/src/App.tsx @@ -0,0 +1,10 @@ +import { SettingsProvider } from './context/settings-context' +import { SaferpaySettings } from './components/settings/saferpay-settings' + +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 new file mode 100644 index 000000000..d75d68602 --- /dev/null +++ b/views/js/admin/settings-app/src/api/client.ts @@ -0,0 +1,59 @@ +import type { PaymentMethodData, TerminalOption } from '@/types' + +function getConfig() { + return window.saferpaySettingsData +} + +async function postAjax(action: string, data: Record = {}) { + const config = getConfig() + const separator = config.ajaxUrl.includes('?') ? '&' : '?' + const url = `${config.ajaxUrl}${separator}ajax=1&action=${action}` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`) + } + + return response.json() +} + +export async function saveCredentials(data: Record): Promise<{ success: boolean; message?: string }> { + return postAjax('saveCredentials', data) +} + +export async function savePaymentProcessing(data: Record): Promise<{ success: boolean; message?: string }> { + return postAjax('savePaymentProcessing', data) +} + +export async function saveEmailSettings(data: Record): Promise<{ success: boolean; message?: string }> { + return postAjax('saveEmailSettings', data) +} + +export async function saveGeneralSettings(data: Record): Promise<{ success: boolean; message?: string }> { + return postAjax('saveGeneralSettings', data) +} + +export async function savePaymentMethods(methods: PaymentMethodData[]): Promise<{ success: boolean; message?: string }> { + return postAjax('savePaymentMethods', { paymentMethods: methods }) +} + +export async function getTerminals( + env: string, + username: string, + password: string, + customerId: string, +): Promise<{ success: boolean; terminals: TerminalOption[] }> { + return postAjax('getTerminals', { env, username, password, customerId }) +} + +export async function refreshData(): Promise<{ success: boolean; data: Record }> { + return postAjax('refreshData') +} 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 new file mode 100644 index 000000000..09d0bbfab --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/api-credentials.tsx @@ -0,0 +1,300 @@ +import { useState, useEffect } 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 { useSettings } from '@/context/settings-context' +import type { TerminalOption } from '@/types' + +export function ApiCredentials() { + const { settings, updateSettings, saveCredentials, fetchTerminals, saving } = useSettings() + const [showApiPassword, setShowApiPassword] = useState(false) + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + const [terminals, setTerminals] = useState([]) + + const environment = settings.testMode ? 'test' : 'live' + const isTest = settings.testMode + + 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 + const fieldJsUrl = isTest ? settings.testFieldJsUrl : settings.liveFieldJsUrl + const hasBusinessLicense = isTest ? settings.testBusinessLicense : settings.liveBusinessLicense + + const prefix = isTest ? 'test' : 'live' + const setField = (field: string, value: string | boolean) => { + updateSettings({ [`${prefix}${field.charAt(0).toUpperCase() + field.slice(1)}`]: value } as Record) + } + + const envLabel = isTest ? 'Test' : 'Live' + const hasCredentials = username.length > 0 && password.length > 0 + + const handleFetchTerminals = async () => { + if (!hasCredentials) return + setIsLoadingTerminals(true) + try { + const result = await fetchTerminals(environment, username, password, customerId) + setTerminals(result) + } finally { + setIsLoadingTerminals(false) + } + } + + useEffect(() => { + if (hasCredentials && terminals.length === 0) { + handleFetchTerminals() + } + }, [environment]) + + return ( +
+ {/* Environment Selector */} + + +
+
+ Environment + + Select your active environment. Credentials are stored separately for each. + +
+ +
+
+ +
+ {isTest ? ( + + ) : ( + + )} + + {isTest + ? 'You are currently in test mode. No real transactions will be processed.' + : 'You are in live mode. Real transactions will be processed.'} + +
+
+
+ + {/* API Credentials */} + + +
+ +
+ + {envLabel} API Credentials + + + Enter your Saferpay {envLabel.toLowerCase()} environment API credentials. + +
+
+
+ +
+
+
+ + setField('username', e.target.value)} + /> +
+
+ +
+ setField('password', e.target.value)} + className="sp-pr-10" + /> + +
+
+
+ +
+
+ + setField('customerId', e.target.value)} + /> +
+
+ +
+ + +
+ {!hasCredentials && ( +

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

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

+ Separate multiple email addresses with commas. +

+
+
+
+
+ + {/* Saferpay Fields Configuration */} + + + Saferpay Fields + + Configure Saferpay Fields for inline payment form integration. + + + +
+
+ +

+ Saferpay Field Access Token can be found in Saferpay Backoffice, navigate to{' '} + {'Settings > Saferpay Fields Access Tokens'}. +

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

+ Enable if you have a Saferpay Business license for advanced features. +

+
+ setField('businessLicense', checked)} + /> +
+
+
+
+ +
+ +
+
+ ) +} 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 new file mode 100644 index 000000000..7c5b702d1 --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/email-notifications.tsx @@ -0,0 +1,97 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +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' + +export function EmailNotifications() { + const { settings, updateSettings, saveEmailSettings, saving } = useSettings() + + return ( +
+ + +
+ +
+ Email Sending + + Configure which emails are sent during the payment process. + +
+
+
+ +
+
+
+ +

+ With this setting enabled an email from the Saferpay system will be sent to the customer. +

+
+ updateSettings({ allowSaferpayMail: checked })} + /> +
+ +
+
+ +

+ Receive a notification when an order is authorized by Saferpay (Using the Mail alert module). +

+
+ updateSettings({ sendNewOrderMail: checked })} + /> +
+ +
+
+ +

+ Send an email from Saferpay on payment completion. +

+
+ updateSettings({ sendOrderConfMail: checked })} + /> +
+ +
+ +
+

+ When this feature is enabled, a confirmation email will be only sent once the payment is authorized by Saferpay. +

+

+ For this feature to be functioning you need to have the Mail Alert module configured. +

+
+
+
+
+
+ +
+ +
+
+ ) +} 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 new file mode 100644 index 000000000..6f6ff92e6 --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/general-settings.tsx @@ -0,0 +1,138 @@ +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 { Settings2, Paintbrush, ClipboardList, Loader2 } from 'lucide-react' +import { useSettings } from '@/context/settings-context' + +export function GeneralSettings() { + const { settings, updateSettings, saveGeneralSettings, saving } = useSettings() + + return ( +
+ {/* Order State */} + + +
+ +
+ Order State + + Define the default order status for Saferpay payments. + +
+
+
+ +
+ + +

+ Default status on SaferPay order creation. +

+
+
+
+ + {/* Styling */} + + +
+ +
+ Styling + + Customize the appearance of the payment page. + +
+
+
+ +
+ + updateSettings({ configurationName: e.target.value })} + /> +

+ This name is visible in payment page and also in payment confirmation email. +

+
+
+
+ + {/* Configuration */} + + +
+ +
+ Configuration + + General module configuration settings. + +
+
+
+ +
+
+ + updateSettings({ paymentDescription: e.target.value })} + /> +

+ This description is visible in payment page also in payment confirmation email. +

+
+ +
+
+ +

+ Enable debug mode to see more information in logs. +

+
+ updateSettings({ debugMode: checked })} + /> +
+
+
+
+ +
+ +
+
+ ) +} 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 new file mode 100644 index 000000000..48f31d8db --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/payment-methods.tsx @@ -0,0 +1,299 @@ +import { useState, useCallback, useMemo, useEffect } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +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' + +function MultiSelect({ + options, + selected, + onChange, + placeholder, + label, +}: { + options: Array<{ id: number; name: string }> + selected: number[] + onChange: (values: number[]) => void + placeholder: string + label: string +}) { + const [search, setSearch] = useState('') + + const filteredOptions = useMemo( + () => options.filter((opt) => opt.name.toLowerCase().includes(search.toLowerCase())), + [options, search], + ) + + const toggle = useCallback( + (value: number) => { + onChange( + selected.includes(value) + ? selected.filter((s) => s !== value) + : [...selected, value], + ) + }, + [selected, onChange], + ) + + return ( + + + + + +
+
+ + setSearch(e.target.value)} + placeholder="Search..." + className="sp-h-8 sp-pl-7 sp-text-xs" + /> +
+
+
+ {filteredOptions.map((option) => ( + + ))} + {filteredOptions.length === 0 && ( +

+ No results found. +

+ )} +
+ {selected.length > 0 && ( +
+ +
+ )} +
+
+ ) +} + +export function PaymentMethods() { + const { paymentMethods, updatePaymentMethod, savePaymentMethods, saving, settings, refreshPaymentMethods } = useSettings() + + useEffect(() => { + if (paymentMethods.length === 0) { + refreshPaymentMethods() + } + }, []) + + const enabledCount = paymentMethods.filter((m) => m.enabled).length + + return ( +
+ + +
+
+ +
+ Payment Methods + + Enable and configure available payment methods for your checkout. + +
+
+ {enabledCount > 0 && ( + + {enabledCount} active + + )} +
+
+ + {/* Header row */} +
+ Payment method + Enabled + Logos + Custom form + Countries + Currencies +
+ + {/* Payment method rows */} +
+ {paymentMethods.map((method) => ( +
+ {/* Desktop layout */} +
+
+ {method.displayName} +
+ +
+ updatePaymentMethod(method.name, { enabled: checked })} + aria-label={`Enable ${method.displayName}`} + /> +
+ +
+ updatePaymentMethod(method.name, { showLogos: checked })} + aria-label={`Show logos for ${method.displayName}`} + /> +
+ +
+ {method.hasCustomForm ? ( + updatePaymentMethod(method.name, { showCustomForm: checked })} + aria-label={`Show custom form for ${method.displayName}`} + /> + ) : ( + -- + )} +
+ + updatePaymentMethod(method.name, { countries })} + placeholder="Select countries" + label={`Countries for ${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}`} + /> +
+ + {/* Mobile layout */} +
+
+ {method.displayName} + updatePaymentMethod(method.name, { enabled: checked })} + aria-label={`Enable ${method.displayName}`} + /> +
+ +
+
+ + updatePaymentMethod(method.name, { showLogos: checked })} + /> +
+ {method.hasCustomForm && ( +
+ + updatePaymentMethod(method.name, { showCustomForm: checked })} + /> +
+ )} +
+ +
+
+ + updatePaymentMethod(method.name, { countries })} + placeholder="Select" + label={`Countries for ${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}`} + /> +
+
+
+
+ ))} + + {paymentMethods.length === 0 && ( +
+ No payment methods available. Please configure your API credentials first. +
+ )} +
+
+
+ +
+ +
+
+ ) +} 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 new file mode 100644 index 000000000..c57c9034b --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/payment-processing.tsx @@ -0,0 +1,296 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +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' + +export function PaymentProcessing() { + const { settings, updateSettings, savePaymentProcessing, saving } = useSettings() + + return ( +
+ {/* Transaction Handling */} + + +
+ +
+ Transaction Handling + + Configure how payments are processed, authorized, and captured. + +
+
+
+ +
+ {/* Default Payment Behavior */} +
+
+ +

+ How payment provider should behave when order is created. +

+
+ updateSettings({ paymentBehavior: Number(val) })} + className="sp-flex sp-gap-3" + > + + + +
+ + {/* 3D Secure Behavior */} +
+
+ +

+ Default payment behavior for payment without 3-D Secure. +

+
+ updateSettings({ paymentBehaviorWithout3D: Number(val) })} + className="sp-flex sp-gap-3" + > + + + +
+ + {/* Restrict Refund */} +
+
+ +

+ If set to true, the refund will be rejected if the sum of authorized refunds exceeds the capture value. +

+
+ updateSettings({ restrictRefund: Number(val) })} + className="sp-flex sp-gap-3" + > + + + +
+ + {/* Order Creation Rule */} +
+
+ +

+ Select the option to determine whether the order should be created. +

+
+ updateSettings({ orderCreationAfterAuth: Number(val) })} + className="sp-flex sp-gap-3" + > + + + +
+
+
+
+ + {/* Card Display & Saving */} + + +
+ +
+ Card Display & Saving + + Configure how cards appear at checkout and whether customers can save them. + +
+
+
+ +
+
+
+ +

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

+
+ updateSettings({ groupCards: checked })} + /> +
+ +
+
+ +

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

+
+
+ +
+
+ +

+ Allow customers to save credit card for faster purchase. +

+
+ updateSettings({ creditCardSave: Number(val) })} + className="sp-flex sp-gap-3" + > + + + +
+
+
+
+ +
+ +
+
+ ) +} 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 new file mode 100644 index 000000000..6b232221c --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/saferpay-settings.tsx @@ -0,0 +1,85 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ApiCredentials } from './api-credentials' +import { PaymentProcessing } from './payment-processing' +import { PaymentMethods } from './payment-methods' +import { EmailNotifications } from './email-notifications' +import { GeneralSettings } from './general-settings' +import { ToastContainer } from './toast-container' +import { Key, CreditCard, Wallet, Mail, Settings2 } from 'lucide-react' + +export function SaferpaySettings() { + return ( +
+
+

+ Saferpay Settings +

+

+ Configure your Saferpay payment integration for your Prestashop store. +

+
+ + + + + + API Credentials + + + + Payment Methods + + + + Payment Processing + + + + Email Notifications + + + + General Settings + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} 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 new file mode 100644 index 000000000..b1162ab36 --- /dev/null +++ b/views/js/admin/settings-app/src/components/settings/toast-container.tsx @@ -0,0 +1,25 @@ +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}
} + {t.description &&
{t.description}
} +
+ ))} +
+ ) +} diff --git a/views/js/admin/settings-app/src/components/ui/alert.tsx b/views/js/admin/settings-app/src/components/ui/alert.tsx new file mode 100644 index 000000000..b88b9053f --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'sp-relative sp-w-full sp-rounded-lg sp-border sp-p-4 [&>svg~*]:sp-pl-7 [&>svg+div]:sp-translate-y-[-3px] [&>svg]:sp-absolute [&>svg]:sp-left-4 [&>svg]:sp-top-4 [&>svg]:sp-text-foreground', + { + variants: { + variant: { + default: 'sp-bg-background sp-text-foreground', + destructive: + 'sp-border-destructive/50 sp-text-destructive [&>svg]:sp-text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/views/js/admin/settings-app/src/components/ui/badge.tsx b/views/js/admin/settings-app/src/components/ui/badge.tsx new file mode 100644 index 000000000..7bafa84a1 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'sp-inline-flex sp-items-center sp-rounded-full sp-border sp-px-2.5 sp-py-0.5 sp-text-xs sp-font-semibold sp-transition-colors focus:sp-outline-none focus:sp-ring-2 focus:sp-ring-ring focus:sp-ring-offset-2', + { + variants: { + variant: { + default: 'sp-border-transparent sp-bg-primary sp-text-primary-foreground hover:sp-bg-primary/80', + secondary: 'sp-border-transparent sp-bg-secondary sp-text-secondary-foreground hover:sp-bg-secondary/80', + destructive: 'sp-border-transparent sp-bg-destructive sp-text-destructive-foreground hover:sp-bg-destructive/80', + outline: 'sp-text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/views/js/admin/settings-app/src/components/ui/button.tsx b/views/js/admin/settings-app/src/components/ui/button.tsx new file mode 100644 index 000000000..a71b3fbd1 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "sp-inline-flex sp-items-center sp-justify-center sp-gap-2 sp-whitespace-nowrap sp-rounded-md sp-text-sm sp-font-medium sp-ring-offset-background sp-transition-colors focus-visible:sp-outline-none focus-visible:sp-ring-2 focus-visible:sp-ring-ring focus-visible:sp-ring-offset-2 disabled:sp-pointer-events-none disabled:sp-opacity-50 [&_svg]:sp-pointer-events-none [&_svg]:sp-size-4 [&_svg]:sp-shrink-0", + { + variants: { + variant: { + default: + "sp-bg-primary sp-text-primary-foreground hover:sp-bg-primary/90", + destructive: + "sp-bg-destructive sp-text-destructive-foreground hover:sp-bg-destructive/90", + outline: + "sp-border sp-border-input sp-bg-background hover:sp-bg-accent hover:sp-text-accent-foreground", + secondary: + "sp-bg-secondary sp-text-secondary-foreground hover:sp-bg-secondary/80", + ghost: "hover:sp-bg-accent hover:sp-text-accent-foreground", + link: "sp-text-primary sp-underline-offset-4 hover:sp-underline", + }, + size: { + default: "sp-h-10 sp-px-4 sp-py-2", + sm: "sp-h-9 sp-rounded-md sp-px-3", + lg: "sp-h-11 sp-rounded-md sp-px-8", + icon: "sp-h-10 sp-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends + React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/views/js/admin/settings-app/src/components/ui/card.tsx b/views/js/admin/settings-app/src/components/ui/card.tsx new file mode 100644 index 000000000..aa1bc9ff4 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/views/js/admin/settings-app/src/components/ui/checkbox.tsx b/views/js/admin/settings-app/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..2ff67ed97 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/views/js/admin/settings-app/src/components/ui/input.tsx b/views/js/admin/settings-app/src/components/ui/input.tsx new file mode 100644 index 000000000..1ae8b9178 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + }, +) +Input.displayName = 'Input' + +export { Input } diff --git a/views/js/admin/settings-app/src/components/ui/label.tsx b/views/js/admin/settings-app/src/components/ui/label.tsx new file mode 100644 index 000000000..94983e39e --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const labelVariants = cva( + 'sp-text-sm sp-font-medium sp-leading-none peer-disabled:sp-cursor-not-allowed peer-disabled:sp-opacity-70', +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/views/js/admin/settings-app/src/components/ui/popover.tsx b/views/js/admin/settings-app/src/components/ui/popover.tsx new file mode 100644 index 000000000..b41db63a5 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import * as PopoverPrimitive from '@radix-ui/react-popover' + +import { cn } from '@/lib/utils' + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + ('.sp-saferpay-root')}> + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/views/js/admin/settings-app/src/components/ui/radio-group.tsx b/views/js/admin/settings-app/src/components/ui/radio-group.tsx new file mode 100644 index 000000000..e3376efd5 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { Circle } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/views/js/admin/settings-app/src/components/ui/select.tsx b/views/js/admin/settings-app/src/components/ui/select.tsx new file mode 100644 index 000000000..d64df3b54 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/select.tsx @@ -0,0 +1,156 @@ +import * as React from 'react' +import * as SelectPrimitive from '@radix-ui/react-select' +import { Check, ChevronDown, ChevronUp } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:sp-line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + ('.sp-saferpay-root')}> + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/views/js/admin/settings-app/src/components/ui/separator.tsx b/views/js/admin/settings-app/src/components/ui/separator.tsx new file mode 100644 index 000000000..c4a67a9cc --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import * as SeparatorPrimitive from '@radix-ui/react-separator' + +import { cn } from '@/lib/utils' + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref, + ) => ( + + ), +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/views/js/admin/settings-app/src/components/ui/switch.tsx b/views/js/admin/settings-app/src/components/ui/switch.tsx new file mode 100644 index 000000000..081dce0fd --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import * as SwitchPrimitives from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/views/js/admin/settings-app/src/components/ui/tabs.tsx b/views/js/admin/settings-app/src/components/ui/tabs.tsx new file mode 100644 index 000000000..4dd5a0f38 --- /dev/null +++ b/views/js/admin/settings-app/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import * as TabsPrimitive from '@radix-ui/react-tabs' + +import { cn } from '@/lib/utils' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/views/js/admin/settings-app/src/context/settings-context.tsx b/views/js/admin/settings-app/src/context/settings-context.tsx new file mode 100644 index 000000000..b20e78fd6 --- /dev/null +++ b/views/js/admin/settings-app/src/context/settings-context.tsx @@ -0,0 +1,162 @@ +import React, { createContext, useContext, useState, useCallback } from 'react' +import type { SaferpaySettingsData, PaymentMethodData, TerminalOption } from '@/types' +import * as api from '@/api/client' +import { toast } from '@/hooks/use-toast' + +interface SettingsContextValue { + settings: SaferpaySettingsData + updateSettings: (updates: Partial) => void + saveCredentials: () => Promise + savePaymentProcessing: () => Promise + saveEmailSettings: () => Promise + saveGeneralSettings: () => Promise + savePaymentMethods: () => Promise + fetchTerminals: (env: string, username: string, password: string, customerId: string) => Promise + refreshPaymentMethods: () => Promise + paymentMethods: PaymentMethodData[] + updatePaymentMethod: (name: string, updates: Partial) => void + saving: boolean +} + +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 updateSettings = useCallback((updates: Partial) => { + setSettings((prev) => ({ ...prev, ...updates })) + }, []) + + const updatePaymentMethod = useCallback((name: string, updates: Partial) => { + setPaymentMethods((prev) => + prev.map((m) => (m.name === name ? { ...m, ...updates } : m)), + ) + }, []) + + const handleSave = useCallback(async (saveFn: () => Promise<{ success: boolean; message?: string }>, label: string) => { + setSaving(true) + try { + const result = await saveFn() + if (result.success) { + toast({ title: `${label} saved successfully`, variant: 'default' }) + } else { + toast({ title: result.message || `Failed to save ${label}`, variant: 'destructive' }) + } + } catch (e) { + toast({ title: `Error saving ${label}`, variant: 'destructive' }) + } finally { + setSaving(false) + } + }, []) + + const saveCredentials = useCallback(async () => { + 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]) + + const savePaymentProcessingFn = useCallback(async () => { + 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]) + + const saveEmailSettingsFn = useCallback(async () => { + await handleSave(() => api.saveEmailSettings({ + allowSaferpayMail: settings.allowSaferpayMail, + sendNewOrderMail: settings.sendNewOrderMail, + sendOrderConfMail: settings.sendOrderConfMail, + }), 'Email Settings') + }, [settings, handleSave]) + + const saveGeneralSettingsFn = useCallback(async () => { + await handleSave(() => api.saveGeneralSettings({ + orderStateAwaitingPayment: settings.orderStateAwaitingPayment, + paymentDescription: settings.paymentDescription, + configurationName: settings.configurationName, + debugMode: settings.debugMode, + }), 'General Settings') + }, [settings, handleSave]) + + const savePaymentMethodsFn = useCallback(async () => { + await handleSave(() => api.savePaymentMethods(paymentMethods), '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[]) + } + } catch { + // silently fail, user still has initial data + } + }, []) + + const fetchTerminals = useCallback(async (env: string, username: string, password: string, customerId: string) => { + try { + const result = await api.getTerminals(env, username, password, customerId) + if (result.success) { + return result.terminals + } + toast({ title: 'Failed to fetch terminals', variant: 'destructive' }) + return [] + } catch { + toast({ title: 'Error fetching terminals', variant: 'destructive' }) + return [] + } + }, []) + + return ( + + {children} + + ) +} + +export function useSettings() { + const context = useContext(SettingsContext) + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider') + } + return context +} diff --git a/views/js/admin/settings-app/src/globals.css b/views/js/admin/settings-app/src/globals.css new file mode 100644 index 000000000..084c1fe3b --- /dev/null +++ b/views/js/admin/settings-app/src/globals.css @@ -0,0 +1,42 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.sp-saferpay-root { + --sp-background: 210 20% 97%; + --sp-foreground: 220 20% 10%; + --sp-card: 0 0% 100%; + --sp-card-foreground: 220 20% 10%; + --sp-popover: 0 0% 100%; + --sp-popover-foreground: 220 20% 10%; + --sp-primary: 180 51% 31%; + --sp-primary-foreground: 0 0% 100%; + --sp-secondary: 180 15% 95%; + --sp-secondary-foreground: 220 20% 10%; + --sp-muted: 180 12% 95%; + --sp-muted-foreground: 220 10% 46%; + --sp-accent: 180 25% 92%; + --sp-accent-foreground: 180 51% 22%; + --sp-destructive: 0 72% 51%; + --sp-destructive-foreground: 0 0% 100%; + --sp-border: 214 20% 90%; + --sp-input: 214 20% 90%; + --sp-ring: 180 51% 31%; + --sp-radius: 0.625rem; + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; + line-height: 1.5; + color: hsl(var(--sp-foreground)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.sp-saferpay-root *, +.sp-saferpay-root *::before, +.sp-saferpay-root *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid hsl(var(--sp-border)); +} diff --git a/views/js/admin/settings-app/src/hooks/use-mobile.tsx b/views/js/admin/settings-app/src/hooks/use-mobile.tsx new file mode 100644 index 000000000..4331d5c56 --- /dev/null +++ b/views/js/admin/settings-app/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..a0dadc74c --- /dev/null +++ b/views/js/admin/settings-app/src/hooks/use-toast.ts @@ -0,0 +1,103 @@ +import * as React from 'react' + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 5000 + +export interface ToasterToast { + id: string + title?: string + description?: string + variant?: 'default' | 'destructive' +} + +type Action = + | { type: 'ADD_TOAST'; toast: ToasterToast } + | { type: 'UPDATE_TOAST'; toast: Partial & { id: string } } + | { type: 'DISMISS_TOAST'; toastId?: string } + | { type: 'REMOVE_TOAST'; toastId?: string } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +let count = 0 +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +function addToRemoveQueue(toastId: string) { + if (toastTimeouts.has(toastId)) return + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ type: 'REMOVE_TOAST', toastId }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) } + case 'UPDATE_TOAST': + return { ...state, toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)) } + case 'DISMISS_TOAST': { + const { toastId } = action + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((t) => addToRemoveQueue(t.id)) + } + return state + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) return { ...state, toasts: [] } + return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId) } + } +} + +const listeners: Array<(state: State) => void> = [] +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => listener(memoryState)) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + dispatch({ type: 'ADD_TOAST', toast: { ...props, id } }) + dispatch({ type: 'DISMISS_TOAST', toastId: id }) + + return { + id, + dismiss: () => dispatch({ type: 'DISMISS_TOAST', toastId: id }), + update: (props: Partial) => dispatch({ type: 'UPDATE_TOAST', toast: { ...props, id } }), + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) listeners.splice(index, 1) + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + } +} + +export { useToast, toast } diff --git a/views/js/admin/settings-app/src/lib/utils.ts b/views/js/admin/settings-app/src/lib/utils.ts new file mode 100644 index 000000000..fed2fe91e --- /dev/null +++ b/views/js/admin/settings-app/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/views/js/admin/settings-app/src/main.tsx b/views/js/admin/settings-app/src/main.tsx new file mode 100644 index 000000000..be0a207eb --- /dev/null +++ b/views/js/admin/settings-app/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './globals.css' + +document.addEventListener('DOMContentLoaded', () => { + const rootEl = document.getElementById('saferpay-settings-root') + if (rootEl) { + 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 new file mode 100644 index 000000000..a6a598df1 --- /dev/null +++ b/views/js/admin/settings-app/src/types/index.ts @@ -0,0 +1,76 @@ +export interface PaymentMethodData { + name: string + displayName: string + enabled: boolean + showLogos: boolean + showCustomForm: boolean + hasCustomForm: boolean + countries: number[] + currencies: number[] +} + +export interface TerminalOption { + id: string + name: string +} + +export interface SaferpaySettingsData { + // Environment + testMode: boolean + + // Test credentials + testUsername: string + testPassword: string + testCustomerId: string + testTerminalId: string + testMerchantEmails: string + testFieldAccessToken: string + testFieldJsUrl: string + testBusinessLicense: boolean + + // Live credentials + liveUsername: string + livePassword: string + liveCustomerId: string + liveTerminalId: string + liveMerchantEmails: string + liveFieldAccessToken: string + liveFieldJsUrl: string + liveBusinessLicense: boolean + + // Payment Processing + paymentBehavior: number + paymentBehaviorWithout3D: number + restrictRefund: number + orderCreationAfterAuth: number + groupCards: boolean + groupCardsLogo: boolean + creditCardSave: number + + // Email + allowSaferpayMail: boolean + sendNewOrderMail: boolean + sendOrderConfMail: boolean + + // General + orderStateAwaitingPayment: number + paymentDescription: string + configurationName: string + debugMode: boolean + + // Reference data + orderStates: Array<{ id: number; name: string }> + countries: Array<{ id: number; name: string }> + currencies: Array<{ id: number; iso_code: string }> + paymentMethods: PaymentMethodData[] + + // Endpoints + ajaxUrl: string + adminToken: string +} + +declare global { + interface Window { + saferpaySettingsData: SaferpaySettingsData + } +} diff --git a/views/js/admin/settings-app/tailwind.config.ts b/views/js/admin/settings-app/tailwind.config.ts new file mode 100644 index 000000000..58d3b930c --- /dev/null +++ b/views/js/admin/settings-app/tailwind.config.ts @@ -0,0 +1,71 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + prefix: 'sp-', + important: '.sp-saferpay-root', + darkMode: ['class'], + content: ['./src/**/*.{ts,tsx}'], + corePlugins: { + preflight: false, + }, + theme: { + extend: { + colors: { + background: 'hsl(var(--sp-background))', + foreground: 'hsl(var(--sp-foreground))', + card: { + DEFAULT: 'hsl(var(--sp-card))', + foreground: 'hsl(var(--sp-card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--sp-popover))', + foreground: 'hsl(var(--sp-popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--sp-primary))', + foreground: 'hsl(var(--sp-primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--sp-secondary))', + foreground: 'hsl(var(--sp-secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--sp-muted))', + foreground: 'hsl(var(--sp-muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--sp-accent))', + foreground: 'hsl(var(--sp-accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--sp-destructive))', + foreground: 'hsl(var(--sp-destructive-foreground))', + }, + border: 'hsl(var(--sp-border))', + input: 'hsl(var(--sp-input))', + ring: 'hsl(var(--sp-ring))', + }, + borderRadius: { + lg: 'var(--sp-radius)', + md: 'calc(var(--sp-radius) - 2px)', + sm: 'calc(var(--sp-radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +} +export default config diff --git a/views/js/admin/settings-app/tsconfig.json b/views/js/admin/settings-app/tsconfig.json new file mode 100644 index 000000000..22413dda4 --- /dev/null +++ b/views/js/admin/settings-app/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src"] +} diff --git a/views/js/admin/settings-app/vite.config.ts b/views/js/admin/settings-app/vite.config.ts new file mode 100644 index 000000000..c44b8be17 --- /dev/null +++ b/views/js/admin/settings-app/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + base: './', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + build: { + outDir: '../dist', + emptyOutDir: true, + rollupOptions: { + output: { + entryFileNames: 'saferpay-settings.js', + assetFileNames: 'saferpay-settings.[ext]', + }, + }, + }, +}) diff --git a/views/templates/admin/settings_react.tpl b/views/templates/admin/settings_react.tpl new file mode 100644 index 000000000..34c79ee4a --- /dev/null +++ b/views/templates/admin/settings_react.tpl @@ -0,0 +1,4 @@ +
+ From c52f529e4b896acb93a3690835f6c2fbb2d39f76 Mon Sep 17 00:00:00 2001 From: Justas Date: Thu, 12 Mar 2026 11:51:53 +0200 Subject: [PATCH 02/61] remove unused --- CLAUDE.md | 134 ------------------------------------------------ force_https.sql | 11 ---- 2 files changed, 145 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 force_https.sql diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9619de25b..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,134 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -SaferPay Official is a PrestaShop payment module integrating SIX Payment Services (Worldline). It supports 23+ payment methods (credit cards, digital wallets, bank transfers, Klarna, PayPal, etc.) with features like 3DS, saved card tokenization, and partial refunds. - -- **Namespace:** `Invertus\SaferPay` -- **PrestaShop compatibility:** 1.7.6.1+ -- **PHP platform target:** 5.6 (via composer config) -- **Current version:** 2.0.2 - -## Build & Development Commands - -```bash -# Install dependencies -composer install - -# Run unit tests -composer test-unit -# or: ./vendor/bin/phpunit --configuration tests/Unit/phpunit.xml - -# Run integration tests -composer test-integration - -# Lint (auto-fix with PHP CS Fixer) -make fix-lint # via Docker -./vendor/bin/php-cs-fixer fix # directly - -# Lint dry-run (CI check) -./vendor/bin/php-cs-fixer fix --diff --no-interaction --dry-run - -# PHPStan static analysis (needs _PS_ROOT_DIR_ set) -_PS_ROOT_DIR_=/var/www/html ./vendor/bin/phpstan --configuration=tests/phpstan/phpstan.neon analyse - -# Build production ZIP -make prepare-zip - -# Docker E2E environment (PS 1.7.8.4 example) -make e2e1784p # start containers + seed DB + install module -make bps1784 # just build/install module in running container -``` - -Version-specific PHPStan configs exist at `tests/phpstan/phpstan-{version}.neon` (e.g., `phpstan-1784.neon`). - -## Code Style - -Enforced by `.php_cs.dist` — PSR-2 plus: -- Short array syntax `[]` -- Trailing commas in multiline arrays -- Braces on next line for functions/OOP constructs -- Single space around concatenation (`.`) -- No unused imports, no useless else/return - -## Architecture - -### Entry Point - -`saferpayofficial.php` — Main module class `SaferPayOfficial extends PaymentModule`. Registers hooks, handles install/uninstall, and provides `$this->getService($serviceName)` for DI. - -### Dependency Injection - -Uses **League Container** with auto-wiring via `ReflectionContainer`. Interface-to-implementation bindings are registered in `src/ServiceProvider/BaseServiceProvider.php`. The container is created fresh per `getService()` call in `LeagueServiceContainerProvider`. Services can be overridden via `extend($id, $concrete)` for testing. - -### src/ Layout - -| Directory | Purpose | -|-----------|---------| -| `Adapter/` | Wrappers around PrestaShop globals (Context, Configuration, Tools, Cart) | -| `Api/` | HTTP client (`ApiRequest`) and API service classes (Initialize, Assert, Capture, Refund, Cancel) | -| `Config/SaferPayConfig.php` | All constants: API URLs, config keys, payment method definitions, hook list | -| `Controller/` | Abstract base controllers for admin and front | -| `Core/` | Order actions, payment processing, verification logic | -| `DTO/` | Request and Response data transfer objects for the SaferPay API | -| `Entity/` | PrestaShop ObjectModel entities (12 tables: `saferpay_order`, `saferpay_payment`, `saferpay_card_alias`, etc.) | -| `EntityBuilder/` | Construct entities from API responses | -| `EntityManager/` | Persistence abstraction (`ObjectModelEntityManager`) | -| `Exception/` | Domain exceptions with error codes | -| `Factory/` | Object creation | -| `Install/` | `Installer` and `Uninstaller` — DB table creation, hook registration, default config | -| `Logger/` | Logging to `saferpay_log` table with `LogFormatter` | -| `Presenter/` | Prepare data for templates (admin order page, assertions) | -| `Processor/` | Request processing handlers | -| `Provider/` | Payment type info, restrictions, currencies, redirect URLs | -| `Repository/` | ~20 repository classes for data access, with interface abstractions | -| `Service/` | ~27 service classes: payment init, status updates, mail, cart ops, restriction validation, transaction flows | -| `ServiceProvider/` | League Container setup and base service registration | -| `Utility/` | Helper functions | -| `Validation/` | Input validation | - -### Controllers - -- **Admin** (`controllers/admin/`, 6 controllers): Settings, Payment management, Logs, Order details, Fields, Module redirect -- **Front** (`controllers/front/`, 15 controllers): Payment flow (ajax, validation, iframe, hostedIframe, return, notify), success/fail pages, saved credit cards, pending notifications - -### Payment Transaction Flow - -1. **Initialize** → `SaferPayInitialize` / `InitializeService` — create payment session, redirect customer -2. **Assert** → `SaferPayTransactionAssertion` / `AssertService` — verify payment after return -3. **Authorize/Capture** → `SaferPayTransactionAuthorization` / `AuthorizationService` / `CaptureService` -4. **Refund** → `SaferPayTransactionRefundAssertion` / `RefundService` -5. **Webhook notifications** → `notify.php` / `pendingNotify.php` controllers - -### Database - -12 custom tables prefixed with `saferpay_` (order, payment, card_alias, field, logo, restriction, assert, assert_refund, order_refund, log, country, currency). Schema managed in `src/Install/Installer.php`. Version migrations in `upgrade/install-{version}.php`. - -### Templates & Assets - -Smarty `.tpl` templates in `views/templates/` (admin, front, hook subdirectories). CSS/JS assets in `views/css/` and `views/js/` with admin/front separation. - -## Testing - -- **Unit tests:** `tests/Unit/` — PHPUnit with `tests/Unit/bootstrap.php`; base class `UnitTestCase` -- **Integration tests:** `tests/Integration/` -- **E2E tests:** `cypress/` — Cypress 9.x, run via BrowserStack in CI across PS versions -- **Test DB seeds:** `tests/seed/database/` — SQL dumps per PrestaShop version - -## CI/CD - -GitHub Actions in `.github/workflows/`: -- **cd.yml**: Lint + PHPStan matrix across PS versions, then build artifact ZIP -- **Cypress workflows**: Per-version E2E tests on BrowserStack -- PRs should target the latest release branch (e.g., `release-2.0.0`) - -## Key Configuration - -`src/Config/SaferPayConfig.php` contains: -- API endpoints (test: `test.saferpay.com/api/`, live: `www.saferpay.com/api/`) -- API version constant (`1.45`) -- All `Configuration` key names (`SAFERPAY_TEST_MODE`, `SAFERPAY_USERNAME`, etc.) -- Payment method name constants and supported methods lists -- Hook names the module registers diff --git a/force_https.sql b/force_https.sql deleted file mode 100644 index 3f54a7e48..000000000 --- a/force_https.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Force PrestaShop to use HTTPS/SSL --- Run against your PrestaShop database - --- Enable SSL -UPDATE ps_configuration SET value = '1' WHERE name = 'PS_SSL_ENABLED'; - --- Enable SSL everywhere (force all pages to HTTPS) -UPDATE ps_configuration SET value = '1' WHERE name = 'PS_SSL_ENABLED_EVERYWHERE'; - --- Copy domain to domain_ssl if domain_ssl is empty or different -UPDATE ps_shop_url SET domain_ssl = domain WHERE domain_ssl = '' OR domain_ssl IS NULL; From 076438265126a6e09217384b258b2a4100a2cdff Mon Sep 17 00:00:00 2001 From: Gytautas Zumaras Date: Thu, 12 Mar 2026 16:52:07 +0200 Subject: [PATCH 03/61] 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 6d7ccb15a..c70260db1 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 2e952cc9e..2b0ad9073 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 2d3b6b3f3..6e32d45af 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 000000000..b5589198e --- /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 b2edbdd38..849fe5f53 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 d75d68602..dac2fe45b 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 09d0bbfab..2dccfa0ee 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 7c5b702d1..044239c82 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 6f6ff92e6..70f74b738 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 48f31d8db..bc3fc449f 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 c57c9034b..a24ad5ba7 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 6b232221c..0d8649911 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 b1162ab36..39d96074d 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 b20e78fd6..29e0b3fa9 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 4331d5c56..000000000 --- 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 a0dadc74c..ec2103412 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 be0a207eb..273674a88 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 a6a598df1..ad8d82aa1 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 000000000..131018d00 --- /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 34c79ee4a..5376b30b2 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 04/61] 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 29e0b3fa9..57e9af8be 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 05/61] 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 2b0ad9073..bccaea218 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 b5589198e..77d03e1be 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 dac2fe45b..1708bfe30 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 2dccfa0ee..2e1aa5ed8 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 57e9af8be..3dfd23ff5 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 06/61] 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 bccaea218..dfce1b086 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 3dfd23ff5..e71802dcc 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 ad8d82aa1..a8c88e052 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 07/61] 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 dfce1b086..4e3fa9935 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 000000000..0299875fb --- /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 000000000..0cf139810 --- /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 000000000..a88e410a6 --- /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 77d03e1be..fcb534a1f 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 2e1aa5ed8..663b72d04 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 e71802dcc..6abb19d36 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 09/61] 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 870464e7c..35bb7d25f 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 b5fc37c69..f36c7fe22 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 b0acd4630..75e49ed57 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 ae7879997..b7168ab42 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 03d1545f2..409591863 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 000000000..c221693bc --- /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 a24ad5ba7..c19e98a45 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 10/61] 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 75e49ed57..b0acd4630 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 c221693bc..000000000 --- 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 11/61] 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 bf6d10fe3..70b8e14ad 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 b7168ab42..793d0b8e1 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 6e961489f..a5c40a192 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 409591863..78a00b823 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 70f74b738..6dac7f145 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 6abb19d36..47fd6f1f6 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 a8c88e052..af64ab595 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 12/61] 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 7508d5986..000000000 --- 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 70b8e14ad..71ffd95ec 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 b7313cb4c..f8988d2b8 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 9696c496c..1de0a18c6 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 8da2aae5b..716aa4837 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 0c8259a9d..863cb240b 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 b0acd4630..dd997aa5e 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 ee6227264..000000000 --- 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 cbe8d897d..2188841f0 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 a5c40a192..21fc39835 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 78a00b823..58a315fbd 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 000000000..e69de29bb diff --git a/upgrade/install-1.0.3.php b/upgrade/install-1.0.3.php index 2574c76e5..b4938c7f3 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 8314405ef..b7b39ae00 --- 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 064c2c333..000000000 --- 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 6407bc177..72f482b10 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 6dac7f145..fc077be4c 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 47fd6f1f6..c4ce8f6c3 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 af64ab595..436600c17 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 ee6227264..000000000 --- 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 ee6227264..000000000 --- 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 4c100589d..000000000 --- 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 ee6227264..000000000 --- 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 13/61] 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 58a315fbd..d657fdb0f 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 5bb36a92f..702cff1ec 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 336eda6f0..9b0b08c91 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 4a8eb769a..481b07f96 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 5a3931901..15484be1e 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 044239c82..d79d331ac 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 fc077be4c..8a43d815f 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 bc3fc449f..01149c2a1 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 c19e98a45..3d26f72da 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 62cb43dd4..eadfc7fcd 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'} -
+ -