diff --git a/.distignore b/.distignore
index 0351edb..0b3643e 100644
--- a/.distignore
+++ b/.distignore
@@ -37,4 +37,20 @@ playwright.config.js
/node_modules
/vendor
rave-payment-forms.zip
-
+/client
+eslint.config.cjs
+phpcs.xml.dist
+phpunit.xml.dist
+phpunit-integration.xml.dist
+.npmpackagejsonlintrc.json
+/coverage
+/playwright-report
+/test-results
+/.phpunit.cache
+# Old integrations scaffolding, replaced by includes/integrations/class-flw-integrations.php.
+src/Integrations
+includes/integrations/class-flw-thirdparty-integrations.php
+views/admin-integrations-page.php
+assets/css/admin
+assets/js/admin
+/test-reports
diff --git a/.env.e2e.example b/.env.e2e.example
deleted file mode 100644
index b7ae661..0000000
--- a/.env.e2e.example
+++ /dev/null
@@ -1,17 +0,0 @@
-# Store Base Url
-BASEURL="https://flutterwave-payments.ddev.site"
-# product to be bought
-SIMPLE_PAYMENT_FORM_URL="/payment-form"
-DONATION_PAYMENT_FORM_URL="/donation-form"
-TICKET_PAYMENT_FORM_URL="/ticket-form"
-
-# test customer credentials
-CUSTOMER_EMAIL="developers@flutterwavego.com"
-CUSTOMER_PASSWORD="password"
-
-# Credit Card Information
-CREDIT_CARD_NUMBER="5531886652142950"
-CREDIT_CARD_EXPIRATION="09/32"
-CREDIT_CARD_CVV="564"
-CREDIT_CARD_PIN="3310"
-CREDIT_CARD_OTP="12345"
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 49d1cec..0000000
--- a/.eslintignore
+++ /dev/null
@@ -1,12 +0,0 @@
-!.eslintrc.js
-build
-assets
-bin
-docs
-includes
-i18n/languages
-release
-tasks
-templates
-vendor
-tests
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index 41364c8..0000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,22 +0,0 @@
-module.exports = {
- extends: [],
- rules: {
- // You can use the 'rules' key to override specific settings in the WooCommerce plugin
- '@wordpress/i18n-translator-comments': 'warn',
- '@wordpress/valid-sprintf': 'warn',
- 'jsdoc/check-tag-names': [
- 'error',
- { definedTags: ['jest-environment'] },
- ],
- },
- settings: {
- // List of modules that are externals in our webpack config.
- // This helps the `import/no-extraneous-dependencies` and
- //`import/no-unresolved` rules account for them.
- 'import/core-modules': [
- '@wordpress/i18n',
- '@wordpress/is-shallow-equal',
- '@wordpress/element',
- ],
- },
-};
diff --git a/.github/actions/make-build/action.yml b/.github/actions/make-build/action.yml
index aa586ee..eb03ba7 100644
--- a/.github/actions/make-build/action.yml
+++ b/.github/actions/make-build/action.yml
@@ -22,7 +22,7 @@ runs:
env:
RELEASE_FILENAME: 'rave-payment-forms.zip'
run: |
- npm install
+ npm ci
npm run build
if [[ ! -f $RELEASE_FILENAME ]]; then
diff --git a/.github/workflows/change-review.yml b/.github/workflows/change-review.yml
deleted file mode 100644
index 8066641..0000000
--- a/.github/workflows/change-review.yml
+++ /dev/null
@@ -1,60 +0,0 @@
-name: Review changes on Dev (Commits/PRs)
-
-on:
- push:
- branches: ['master']
- pull_request:
- branches: ['dev']
- types: [opened, synchronize, reopened]
-
-env:
- WP_MIN_SUPPORTED_VERSION: '6.0'
- PHP_MIN_SUPPORTED_VERSION: '7.4'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- generate-wp-compat-matrix:
- name: 'Generate the matrix for wordpress compatibility dynamically'
- runs-on: ubuntu-20.04
- outputs:
- matrix: ${{ steps.generate_matrix.outputs.matrix }}
- steps:
- - name: 'Generate matrix'
- id: generate_matrix
- run: |
- MATRIX_INCLUDE=$( echo "[{\"wordpress\":\"$WP_MIN_SUPPORTED_VERSION\",\"gutenberg\":\"13.6.0\",\"php\":\"$PHP_MIN_SUPPORTED_VERSION\"}]" )
- echo "matrix={\"wordpress\":[\"latest\"],\"gutenberg\":[\"latest\"],\"php\":[\"7.4\"], \"include\":$MATRIX_INCLUDE}" >> $GITHUB_OUTPUT
- phpcs:
- name: PHPCS
- strategy:
- matrix:
- operating-system: ['ubuntu-latest']
- php-versions: ['7.4', '8.0', '8.1']
- phpunit-versions: ['7.5.20']
- runs-on: ${{ matrix.operating-system }}
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
- - name: 'Validate composer.json and composer.lock'
- run: composer validate --strict
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php-versions }}
- ini-values: 'memory_limit=1G'
- coverage: none
- tools: cs2pr
- - name: Install Composer dependencies
- uses: 'ramsey/composer-install@v2'
- - name: Run PHPCS checks
- continue-on-error: true
- run: vendor/bin/phpcs --ignore=*/tests/*,*/data/*,*/assets/* --report-full .
- - name: Git Blame
- continue-on-error: true
- run: vendor/bin/phpcs -s --report=gitblame --ignore=*/tests/*,*/data/*,*/assets/* .
- - name: Run WordPress Checks
- continue-on-error: true
- run: composer run standards:check
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c8bb59e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,157 @@
+name: CI
+
+on:
+ push:
+ branches: ['master', 'dev']
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '8.3'
+ coverage: none
+ tools: cs2pr
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: PHP coding standards
+ run: vendor/bin/phpcs -q --report=checkstyle | cs2pr
+ - name: JavaScript
+ run: npm run lint:js
+ - name: CSS
+ run: npm run lint:css
+ - name: package.json
+ run: npm run lint:pkg-json
+ - name: JavaScript unit tests
+ run: npm run test:js
+ - name: Build admin app
+ run: npm run build:admin
+
+ audit:
+ name: Dependency audit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '8.3'
+ coverage: none
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ - name: npm audit
+ run: npm audit --audit-level=moderate
+ - name: composer audit
+ run: composer audit --locked
+
+ unit:
+ name: Unit (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.4', '8.1', '8.3', '8.4', '8.5']
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: ${{ matrix.php == '8.3' && 'xdebug' || 'none' }}
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ - name: Run unit tests
+ if: matrix.php != '8.3'
+ run: vendor/bin/phpunit --testsuite unit
+ - name: Run unit tests with coverage
+ if: matrix.php == '8.3'
+ run: vendor/bin/phpunit --testsuite unit --coverage-text
+
+ integration:
+ name: Integration (WP ${{ matrix.wp }}, PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - wp: latest
+ php: '8.3'
+ - wp: latest
+ php: '7.4'
+ - wp: '6.4'
+ php: '8.1'
+ env:
+ WP_ENV_PHP_VERSION: ${{ matrix.php }}
+ # '' is falsy in expressions, so the branch must be the && operand, not the || fallback.
+ WP_ENV_CORE: ${{ matrix.wp != 'latest' && format('WordPress/WordPress#{0}-branch', matrix.wp) || '' }}
+ # Only the tests site is used here; the EDD and GiveWP activation in .wp-env.json is for e2e.
+ WP_ENV_LIFECYCLE_SCRIPT_AFTER_START: wp-env run tests-cli wp plugin activate rave-payment-forms
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '7.4'
+ coverage: none
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ # build/ is not committed; without it the admin app tests are skipped.
+ - run: npm run build:admin
+ # wp-env activates every listed plugin on start, and EDD/GiveWP require newer WordPress than the matrix floor.
+ - run: |
+ echo '{ "plugins": [] }' > .wp-env.override.json
+ - run: npm run env:start
+ - run: npm run test:integration
+
+ e2e:
+ name: End-to-end
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '7.4'
+ coverage: none
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ with:
+ composer-options: '--no-dev'
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ # build/ is not committed; the admin screens and blocks need it.
+ - run: npm run build:admin
+ - run: npm run test:e2e:install
+ - run: npm run env:start
+ - run: npm run test:e2e
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ id: report
+ if: always()
+ with:
+ name: playwright-report
+ path: |
+ playwright-report
+ test-reports
+ test-results
+ retention-days: 7
+ if-no-files-found: warn
+ - name: Summary
+ if: always()
+ run: node tests/e2e/report/summary.js test-reports/e2e-results.json "${{ steps.report.outputs.artifact-url }}" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/e2e-report.yml b/.github/workflows/e2e-report.yml
new file mode 100644
index 0000000..b5156f9
--- /dev/null
+++ b/.github/workflows/e2e-report.yml
@@ -0,0 +1,71 @@
+name: E2E report
+
+# A weekly end-to-end run with a report, so breakage from new WordPress or
+# browser releases shows up even in weeks without commits.
+on:
+ schedule:
+ # Fridays at 12:00 UTC. GitHub schedules always run in UTC: for 12:00
+ # West Africa Time (UTC+1) use '0 11 * * 5'. Schedules only run on the
+ # default branch.
+ - cron: '0 12 * * 5'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: e2e-report
+ cancel-in-progress: false
+
+jobs:
+ e2e-report:
+ name: End-to-end report
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ with:
+ php-version: '7.4'
+ coverage: none
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ with:
+ composer-options: '--no-dev'
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: npm run build:admin
+ - run: npm run test:e2e:install
+ - run: npm run env:start
+
+ # Keep going when tests fail so the report is still published.
+ - name: Run end-to-end tests
+ id: e2e
+ continue-on-error: true
+ run: npm run test:e2e
+
+ - name: Upload report
+ id: report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: e2e-report-${{ github.run_number }}
+ path: |
+ playwright-report
+ test-reports
+ test-results
+ retention-days: 30
+ if-no-files-found: warn
+
+ - name: Write the job summary
+ if: always()
+ run: node tests/e2e/report/summary.js test-reports/e2e-results.json "${{ steps.report.outputs.artifact-url }}" >> "$GITHUB_STEP_SUMMARY"
+
+ # Mark the run red after the report is out, so failures still notify.
+ - name: Fail when tests failed
+ if: steps.e2e.outcome == 'failure'
+ run: |
+ echo "::error::End-to-end tests failed. See the job summary and the e2e-report-${{ github.run_number }} artifact."
+ exit 1
diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml
index 2f9cce7..6730153 100644
--- a/.github/workflows/publish-package.yml
+++ b/.github/workflows/publish-package.yml
@@ -2,6 +2,9 @@ name: Publish to WordPress.org
on:
release:
types: [released]
+permissions:
+ contents: read
+
jobs:
tag:
name: New Release
@@ -9,27 +12,28 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
steps:
- - uses: act10ns/slack@v2
+ - uses: act10ns/slack@d96404edccc6d6467fc7f8134a420c851b1e9054 # v2
with:
status: starting
if: ${{ always() }}
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js environment
- uses: actions/setup-node@v2.5.2
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
- node-version-file: .nvmrc
+ node-version-file: .nvmrc
+ cache: npm
- name: Build Plugin
uses: ./.github/actions/make-build
- name: WordPress Plugin Deploy
id: deploy
- uses: 10up/action-wordpress-plugin-deploy@stable
+ uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # stable
env:
SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
SLUG: rave-payment-forms
- name: push build status to Slack
- uses: 8398a7/action-slack@v3
+ uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest
diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml
index b46d569..600d2e6 100644
--- a/.github/workflows/security-scan.yml
+++ b/.github/workflows/security-scan.yml
@@ -7,6 +7,9 @@ on:
types:
- opened
+permissions:
+ contents: read
+
jobs:
code-check:
runs-on: ubuntu-latest
@@ -14,10 +17,9 @@ jobs:
OS: ubuntu-latest
steps:
- name: checkout code
- uses: actions/checkout@v2
-
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Checkmarx One ClI Action
- uses: checkmarx/ast-github-action@main
+ uses: checkmarx/ast-github-action@882c03f380f4bdddae2d068540cbd73efe0796ac # main
with:
project_name: WordPress
cx_tenant: Flutterwave
diff --git a/.gitignore b/.gitignore
index 0401cc4..5f69b16 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,11 @@ build/
.docker/bin
.docker/logs
.docker/data
-.docker/wordpress
\ No newline at end of file
+.docker/wordpress
+# Tests
+/.phpunit.cache
+/coverage
+/playwright-report
+/test-results
+/tests/e2e/.state
+/test-reports
diff --git a/.npmpackagejsonlintrc.json b/.npmpackagejsonlintrc.json
new file mode 100644
index 0000000..627a598
--- /dev/null
+++ b/.npmpackagejsonlintrc.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@wordpress/npm-package-json-lint-config",
+ "rules": {
+ "valid-values-license": [
+ "error",
+ [
+ "MIT"
+ ]
+ ]
+ }
+}
diff --git a/.nvmrc b/.nvmrc
index 789d3c1..a45fd52 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-16.18.0
\ No newline at end of file
+24
diff --git a/.prettierrc.js b/.prettierrc.js
index e596d24..ca2665d 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -1 +1 @@
-module.exports = require('@wordpress/prettier-config');
+module.exports = require( '@wordpress/prettier-config' );
diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png
index 00b327f..e5b6921 100644
Binary files a/.wordpress-org/screenshot-1.png and b/.wordpress-org/screenshot-1.png differ
diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png
index 83fe959..4ea1d7b 100644
Binary files a/.wordpress-org/screenshot-2.png and b/.wordpress-org/screenshot-2.png differ
diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png
index 6be7cbc..713aeb9 100644
Binary files a/.wordpress-org/screenshot-3.png and b/.wordpress-org/screenshot-3.png differ
diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png
index 0c7d830..e81c3d1 100644
Binary files a/.wordpress-org/screenshot-4.png and b/.wordpress-org/screenshot-4.png differ
diff --git a/.wordpress-org/screenshot-5.png b/.wordpress-org/screenshot-5.png
index b2629a3..cae39b3 100644
Binary files a/.wordpress-org/screenshot-5.png and b/.wordpress-org/screenshot-5.png differ
diff --git a/.wordpress-org/screenshot-6.png b/.wordpress-org/screenshot-6.png
index fa8beeb..648c926 100644
Binary files a/.wordpress-org/screenshot-6.png and b/.wordpress-org/screenshot-6.png differ
diff --git a/.wordpress-org/screenshot-7.png b/.wordpress-org/screenshot-7.png
index 93337aa..b4366a5 100644
Binary files a/.wordpress-org/screenshot-7.png and b/.wordpress-org/screenshot-7.png differ
diff --git a/.wp-env.json b/.wp-env.json
index ddd7167..dba07cf 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -1,10 +1,21 @@
{
- "core": "https://wordpress.org/latest.zip",
- "phpVersion": "7.4",
- "plugins": ["rave-payment-forms.zip"],
- "port": 1000,
- "testsPort": 1001,
+ "$schema": "https://schemas.wp.org/trunk/wp-env.json",
+ "core": null,
+ "phpVersion": "8.3",
+ "plugins": [
+ "https://downloads.wordpress.org/plugin/easy-digital-downloads.3.7.1.zip",
+ "https://downloads.wordpress.org/plugin/give.4.17.0.zip"
+ ],
+ "mappings": {
+ "wp-content/plugins/rave-payment-forms": ".",
+ "wp-content/mu-plugins/flw-e2e-mock.php": "./tests/e2e/fixtures/mock-flutterwave.php"
+ },
"config": {
- "WP_DEBUG_DISPLAY": false
+ "WP_DEBUG": true,
+ "WP_DEBUG_DISPLAY": false,
+ "SCRIPT_DEBUG": true
+ },
+ "lifecycleScripts": {
+ "afterStart": "wp-env run cli wp plugin activate rave-payment-forms easy-digital-downloads give && wp-env run tests-cli wp plugin activate rave-payment-forms"
}
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aab1fd4..451e737 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,53 @@
# Changelog
+## 1.1.0 | 29-09-2026
+
+### Added
+- New onboarding flow for first-time setup (welcome, general details, API & webhook, payment methods, redirects), matching the Flutterwave WooCommerce plugin.
+- Tabbed settings screen replacing the old settings form, with a generated webhook secret hash, copyable webhook URL and test/live key detection.
+- Payment methods are chosen from a list (cards, stablecoin, bank transfer, mobile money, Apple Pay, Google Pay, Opay, plus advanced options) and enforced at checkout.
+- Donation form heading and message are now shown on the form.
+- Blocks: Payment Button, Payment Form, Donation Form, Pricing Card and Accepted Payment Methods, with per-block colours and corner radius.
+- Payment form shortcode: `heading`, `description`, `layout="compact"`, `width="full"` and `show_secured="0"` attributes, `exclude="fullname"`, and `{amount}` in the button text.
+- Donation form shortcode: `heading`, `message`, `currency`, `amounts` (suggested amounts) and `show_frequency="0"` attributes.
+- Integration analytics through the Flutterwave SigNoz service (app.created, request.sent, app.transaction, app.error), ported from the WooCommerce plugin. Events are sent in the background and can be turned off with `FLW_DISABLE_TELEMETRY` or the `flw_signoz_enabled` filter.
+- Payment Forms screen listing every Flutterwave block and shortcode in use, with the page it is on, its status and the payments it has taken, plus a builder that creates a draft page or copies the shortcode with a live preview.
+- Payments now record the page they were made on.
+- Guided tips: a short tour of each admin screen the first time a merchant opens it after onboarding, with a Help menu on every screen to replay a tour or turn tips on and off (saved per user).
+- Integrations screen: install and activate the Flutterwave WooCommerce extension, switch on the new Easy Digital Downloads and GiveWP gateways (paid on Flutterwave's hosted checkout, confirmed on return and by webhook), and see what is coming soon.
+- Custom CSV download of transactions for a date range, with spreadsheet formula injection neutralised.
+
+### Removed
+- The unused third-party integrations scaffolding (exchange-rate service sample and its admin page) is no longer loaded or shipped.
+
+### Changed
+- Payment and donation forms use the new Flutterwave design
+- Transactions screen redesigned: customer, amount, date and status table with date-range, status and currency filters, pagination and a details drawer to re-verify or delete a record., with self-hosted fonts.
+- The payment page title, description and logo settings are now sent to Flutterwave checkout.
+- Form styles only load on pages that contain a form.
+
+### Security
+- [FIXED] Payment amount and currency are enforced server-side through a signed form configuration.
+- [FIXED] Transaction verification checks the stored record's tx_ref, amount and currency instead of transaction metadata.
+- [FIXED] Stored XSS through shortcode attributes and in the transactions list.
+- [FIXED] Webhooks are rejected when no secret hash is configured; hashes are compared in constant time.
+- [FIXED] `update-transaction` requires `manage_options` and a REST nonce.
+- [ADDED] Easy Digital Downloads and GiveWP payments are only changed by a transaction verified with Flutterwave or the signed webhook, never by the return URL's query arguments, and completion is locked so a simultaneous return and webhook cannot complete an order twice.
+- [ADDED] Checkout rate limiting (`flw_checkout_rate_limit` filter) and validated settings.
+- [CHANGED] Secret key and secret hash fields are masked.
+
+### Fixed
+- Checkout showed nothing when Flutterwave rejected the payment request; it now shows an error.
+- The plugin version constant said 1.0.6 while the plugin header said 1.0.7.
+- Name fields were never rendered on the payment form (and `split_name` never showed first/last name).
+- Editors saw the "API keys missing" notice instead of the form on configured sites.
+- Translation loading was triggered too early on WordPress 6.7+.
+- Checkout errors were not shown to customers.
+
+### Development
+- Dependencies upgraded; `npm audit` and `composer audit` report no vulnerabilities.
+- New test suites: unit (PHPUnit + Brain Monkey), integration (WordPress test suite via wp-env) and end-to-end (Playwright).
+- WordPress Coding Standards 3, ESLint flat config, Node.js 24, GitHub Actions pinned to commit SHAs.
+
## 1.0.7 | 29-08-2023
Update admin settings view for Transactions.
diff --git a/Makefile b/Makefile
index 837259a..656c518 100644
--- a/Makefile
+++ b/Makefile
@@ -13,17 +13,26 @@ down:
docker-compose -f .docker/docker-compose.yml down
-dev-js:
- npm run start
+env-start:
+ npm run env:start
-build-production-js:
- npm run preuglify && npm run uglify
+env-stop:
+ npm run env:stop
+
+test:
+ npm test
+
+test-unit:
+ npm run test:unit
-build-production-docs:
- npm run docs:build
+lint:
+ npm run lint
-dev-docs:
- npm run docs:dev
+audit:
+ npm run audit
+
+build-production-js:
+ npm run preuglify && npm run uglify
wp-format:
npm run format
@@ -35,7 +44,7 @@ zip:
rm rave-payment-forms.zip && npm run plugin-zip
inspection:
- ./vendor/bin/phpcs -p . --standard=PHPCompatibilityWP
+ composer run phpcompatibility:check
build:
npm run build
diff --git a/assets/css/admin/integrations.css b/assets/css/admin/integrations.css
index 9870008..7f5b48d 100644
--- a/assets/css/admin/integrations.css
+++ b/assets/css/admin/integrations.css
@@ -1,25 +1,25 @@
#wpbody {
- display: inline-block;
- width: 100%;
- padding-top: 0;
- margin-top: 60px;
+ display: inline-block;
+ width: 100%;
+ padding-top: 0;
+ margin-top: 60px;
}
-#wpbody{
- box-sizing: border-box;
+#wpbody {
+ box-sizing: border-box;
}
.flw__integration {
- background-color: #FFF;
- width: 100%;
- padding: 30px;
- display: flex;
- flex-direction: column;
- margin: 0;
+ background-color: #fff;
+ width: 100%;
+ padding: 30px;
+ display: flex;
+ flex-direction: column;
+ margin: 0;
}
.flutterwave-page .wrap {
- margin: 10px 20px 0 2px;
+ margin: 10px 20px 0 2px;
}
diff --git a/assets/css/admin/integrations.scss b/assets/css/admin/integrations.scss
index f325a55..2d68391 100644
--- a/assets/css/admin/integrations.scss
+++ b/assets/css/admin/integrations.scss
@@ -1,6 +1,6 @@
.flw__integration {
- background-color: #FFF;
- width: 100%;
- margin: auto;
+ background-color: #fff;
+ width: 100%;
+ margin: auto;
}
diff --git a/assets/css/flw-donation.css b/assets/css/flw-donation.css
deleted file mode 100644
index 39bf902..0000000
--- a/assets/css/flw-donation.css
+++ /dev/null
@@ -1,495 +0,0 @@
-h1, h2, h3, h4, h5, h6, p {
- margin: 0;
-}
-
-html {
- box-sizing: border-box;
-}
-
-*, *::after, *::before {
- box-sizing: inherit;
-}
-
-body {
- font-size: 14px;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
- font-weight: 400;
- color: #212b36;
- line-height: 1.5;
- letter-spacing: 0.4px;
- background-color: #F4F6F8;
-}
-
-body {
- margin: 0;
-}
-
-a:active, a:hover {
- outline-width: 0;
-}
-
-.contact{
- padding: 0 30px 0 30px;
- height: 100vh;
- overflow: scroll;
-
-
-
-}
-
-
-.donation__section__left__header {
- margin-top: 50px;
- color: #f4f6f8;
- font-size: 2.5em;
-}
-
-
-.donation__section__left__description {
- margin-top: 30px;
- width: 500px;
- line-height: normal;
- font-size: 20px;
-}
-
-.contact .donation-form {
- width: 360px;
- max-width: 100%;
-}
-
-.contact .donation-form .radio-group {
- margin-bottom: 20px;
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.form__item--marginBottom-12 {
- margin-bottom: 12px;
-}
-
-.form__item {
- margin-bottom: 20px;
-}
-
-@media (max-width: 480px){
-
- .contact .donation-form .radio-group .radio-box-group--left {
- font-size: 12px;
- }
-}
-
-.contact .donation-form .radio-group .radio-box-group--left {
- position: relative;
-}
-
-
-.contact .donation-form .radio-group input[type="radio"] {
- z-index: 100;
- display: none;
-}
-
-.contact .donation-form .radio-group input[type="radio"]:checked + label {
- background: #ffffff;
- color: #12122c;
-}
-
-@media (max-width: 480px){
-
- .contact .donation-form .radio-group label {
- display: block;
- text-align: center;
- padding: 5px 20px;
- font-size: 10px;
- }
-
-}
-
-.contact .donation-form .radio-group label {
- padding: 10px 40px;
- text-transform: uppercase;
- border: 1px solid #f4f6f8;
- font-weight: 600;
- color: #fff;
-}
-
-
-
-@media (max-width: 991px){
- .contact .donation-form {
- width: 360px;
- max-width: 90%;
- margin: 0 auto;
- }
-}
-
-@media (max-width: 480px){
- .donation .donation-form .radio-group .radio-box-group--right {
- font-size: 12px;
- }
-}
-
-.donation .donation-form .radio-group .radio-box-group--right {
- position: relative;
-}
-
-span.phone{
- /* float: right;
- padding: 2em; */
- display: flex;
- align-items: center;
- padding: 0 100px;
- height: 50px;
-
- color: black;
- text-decoration: none;
- font-weight: 600;
- font-size: 12px;
-}
-
-.contact_top {
- display: flex;
- align-items: center;
- padding: 0 100px;
- height: 50px;
-}
-
-@media (max-width: 991px){
-
- .contact_top_left a {
- font-size: 10px;
- }
-
-}
-
-.contact_top_left a {
- text-decoration: none;
- color: black;
- font-weight: 600;
- font-size: 12px;
-}
-
-.contact__top {
- display: flex;
- align-items: center;
- padding: 0 100px;
- height: 50px;
-}
-
-
-
-@media (max-width: 991px){
- .contact__top {
- padding: 15px 0;
- }
-}
-
-
-
-.contact_top_right{
- margin-left: auto;
-}
-
-.contact_top_right a {
- color: black;
- text-decoration: none;
- font-weight: 600;
- font-size: 12px;
-}
-
-@media (max-width: 991px){
- .contact__top__right a {
- font-size: 10px;
- }
-}
-
-
-
-@media (max-width: 991px){
- span.phone {
- padding: 15px 0;
- font-size: 10px;
- }
-}
-
-
-
-
-
-.wrapper{
- background-image: linear-gradient(rgba(18, 18, 44, 0.8), rgba(18, 18, 44, 0.8)), ;
-}
-
-@media (max-width: 480px){
-
- .wrapper {
- overflow: scroll;
- }
-
-}
-
-.wrapper{
- position: relative;
- background-size: cover;
- height: calc(100vh - 90px);
-}
-
-
-.form_bottom{
- position: relative;
- /* background: url(/static/img/donation-bg@3x.png) center no-repeat; */
- background-size: cover;
- height: calc(100vh - 90px);
-}
-
-.form__input {
- display: block;
- width: 100%;
- padding: 10px 12px;
- font-size: 14px;
- line-height: 1.43;
- color: inherit;
- font-family: inherit;
- -webkit-background-clip: padding-box;
- background-clip: padding-box;
- border: 1px solid #c4cdd5;
- border-radius: 4px;
- outline: none;
- resize: none;
- background-color: #ffffff;
- box-shadow: 0 1px 0 0 rgb(22 29 37 / 5%);
- transition: border-color ease-in-out .3s, box-shadow ease-in-out .3s;
-}
-
-.contact .donation-form .form__inner {
- background-color: white;
- padding: 20px;
-}
-
-.textCenter {
- text-align: center;
-}
-
-.form__item--noBottomMargin {
- margin-bottom: 0;
-}
-
-.newObject {
- display: flex;
-}
-
-.newObject__select {
- width: 30%;
-}
-
-.select {
- position: relative;
-}
-
-.contact .donation-form .form__inner .select__input {
- color: #212b36;
-}
-
-.select__input:disabled {
- background-color: #f4f7fc;
- color: #637381;
-}
-
-.select__input--noRightBorderRadius {
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
-}
-
-.select__icon {
- position: absolute;
- right: 11px;
- top: 8px;
- pointer-events: none;
- padding-left: 3px;
- background-color: #ffffff;
-}
-
-svg:not(:root) {
- overflow: hidden;
-}
-
-.newObject__input {
- width: 70%;
-}
-
-
-
-.form__input[readonly] {
- background-color: #f4f7fc;
- color: #637381;
-}
-
-.form__input--noLeftBorderRadius {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
- border-left: 0;
-}
-
-button, input {
- overflow: visible;
-}
-
-button, input, optgroup, select, textarea {
- font-family: sans-serif;
- font-size: 100%;
- line-height: 1.15;
- margin: 0;
-}
-
-.select__input {
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
-/* padding: 16px 12px; */
- font-size: 14px;
- line-height: 1.43;
- color: inherit;
- display: block;
- width: 100%;
- font-family: inherit;
- border: 1px solid #c4cdd5;
- border-radius: 4px;
- outline: none;
- background-color: #ffffff;
- box-shadow: 0 1px 0 0 rgb(22 29 37 / 5%);
- transition: border-color ease-in-out .3s, box-shadow ease-in-out .3s;
-}
-
-.donation__section__right__badge {
- margin: 40px auto;
- display: flex;
-}
-
-.secured__badge {
- margin-bottom: 6px;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.secured__badge__container {
- display: flex;
- border-radius: 3px;
- padding: 6px 12px;
- justify-content: center;
- align-items: center;
- background-color: #000000;
-}
-
-.secured__badge__icon {
- padding-right: 5px;
-}
-
-.secured__badge__text {
- color: #f5a623;
- font-weight: 600;
- font-size: 10px;
-}
-
-.donation__section {
- display: flex;
- height: calc(100vh - 90px);
-}
-
-@media (max-width: 991px){
-
- .donation__section__left {
- display: none;
- }
-
-}
-
-
-
-.donation__section__left {
- width: 60%;
- padding: 50px 100px;
- color: #ffffff;
-}
-
-.donation__section__right {
- width: 40%;
- padding: 80px 30px;
-}
-
-@media (max-width: 991px){
-
- .donation__section__right {
- width: 100%;
- padding: 20px 20px;
- }
-
-}
-
-.text-uppercase {
- text-transform: uppercase;
-}
-
-.btn {
- color: inherit;
- display: inline-block;
- font-weight: 600;
- line-height: 1.71;
- text-align: center;
- white-space: nowrap;
- vertical-align: middle;
- cursor: pointer;
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- border: none;
- border-radius: 4px;
- padding: 8px 16px;
- font-size: 14px;
- text-decoration: none;
- transition: all 0.3s ease-in-out;
- font-family: inherit;
- outline: none;
- box-shadow: 1px 1px 3px 0 rgb(50 50 54 / 13%), 0 0 3px 1px rgb(63 63 68 / 5%);
-}
-
-.btn--pay {
- padding: 8px 32px;
-}
-
-.btn--block {
- display: block;
- width: 100%;
-}
-
-.btn--primary {
- color: #fff;
- background-color: #F5A623;
-}
-
-
-
-button, html [type="button"], [type="reset"], [type="submit"] {
- -webkit-appearance: button;
-}
-
-@media screen and (max-width: 767px){
-
- .hidden--xs {
- display: none !important;
- }
-}
-
-
-@media screen and (min-width: 768px){
-
- .show--xs {
- display: none !important;
- }
-}
-
-
-
-
diff --git a/assets/css/flw-fonts.css b/assets/css/flw-fonts.css
new file mode 100644
index 0000000..9661c9b
--- /dev/null
+++ b/assets/css/flw-fonts.css
@@ -0,0 +1,108 @@
+/**
+ * Self-hosted Flutterwave webfonts, shared by the admin app and the
+ * payment forms.
+ *
+ * Served from the plugin rather than a font CDN: no third-party requests
+ * (which keeps privacy reviews simple) and the files come from the same
+ * origin as the page.
+ * Millik ships a single 400 weight, so headings must not request bold.
+ */
+
+@font-face {
+ font-family: Millik;
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../fonts/Millik.woff2) format("woff2");
+}
+
+@font-face {
+ font-family: Moderat;
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(../fonts/Moderat-Regular.woff2) format("woff2");
+}
+
+@font-face {
+ font-family: Moderat;
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(../fonts/Moderat-Medium.woff2) format("woff2");
+}
+
+@font-face {
+ font-family: Moderat;
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(../fonts/Moderat-Bold.woff2) format("woff2");
+}
+
+/* Inter, the fallback beneath Moderat. Each subset keeps the unicode-range
+ Google generated, so a browser only downloads the ranges a page actually
+ uses — Moderat is Latin-only, so these carry non-Latin locales. */
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-cyrillic-ext-normal.woff2) format("woff2");
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-cyrillic-normal.woff2) format("woff2");
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-greek-ext-normal.woff2) format("woff2");
+ unicode-range: U+1F00-1FFF;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-greek-normal.woff2) format("woff2");
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-vietnamese-normal.woff2) format("woff2");
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-latin-ext-normal.woff2) format("woff2");
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url(../fonts/Inter-latin-normal.woff2) format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/assets/css/flw.css b/assets/css/flw.css
index 4256c41..6915ecf 100644
--- a/assets/css/flw.css
+++ b/assets/css/flw.css
@@ -1,164 +1,628 @@
-.flw-mssing-api-keys {
- color: #ff9b00;
- font-size: 1.5em;
- padding: .5em;
- background: #f4f6f8;
- text-align: center;
+/**
+ * Flutterwave payment and donation forms.
+ *
+ * Shares its tokens with the admin app (client/admin/admin.scss) so a form
+ * looks like the onboarding screens that created it. Selectors use the form's
+ * own classes so theme styles for bare inputs and buttons do not win.
+ */
+
+.flutterwave-payment-form,
+.flutterwave-donation-form {
+
+ /* Blocks override these through --flw-accent, --flw-accent-text
+ and --flw-custom-radius. */
+ --flw-orange: var(--flw-accent, #ff9b00);
+ --flw-on-orange: var(--flw-accent-text, #1a1a1a);
+ --flw-ink: #101828;
+ --flw-body: #475467;
+ --flw-muted: #98a2b3;
+ --flw-border: #d0d5dd;
+ --flw-line: #eaecf0;
+ --flw-radius: var(--flw-custom-radius, 8px);
+ --flw-sans: "Moderat", "Inter", -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif;
+ --flw-serif: "Millik", "Iowan Old Style", "Palatino Linotype", palatino, georgia, serif;
+
+ background: #fff;
+ border: 1px solid var(--flw-line);
+ border-radius: 12px;
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 12px 32px -12px rgba(16, 24, 40, 0.12);
+ box-sizing: border-box;
+ color: var(--flw-ink);
+ font-family: var(--flw-sans);
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 24px auto;
+ max-width: var(--flw-max-width, 440px);
+ padding: 32px;
+ text-align: left;
+ width: 100%;
}
-.flutterwave-overlay {
- position: fixed; /* Sit on top of the page content */
- width: 100%; /* Full width (cover the whole page) */
- height: 100%; /* Full height (cover the whole page) */
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0,0,0,0.5); /* Black background with opacity */
- z-index: 2; /* Specify a stack order in case you're using a different order for other elements */
- cursor: pointer; /* Add a pointer on hover */
+.flutterwave-payment-form *,
+.flutterwave-payment-form *::before,
+.flutterwave-payment-form *::after,
+.flutterwave-donation-form *,
+.flutterwave-donation-form *::before,
+.flutterwave-donation-form *::after {
+ box-sizing: inherit;
}
-.flw-overlay-text {
- position: absolute;
- top: 50%;
- left: 50%;
- font-size: 50px;
- color: white;
- transform: translate(-50%,-50%);
- -ms-transform: translate(-50%,-50%);
-}
-
-.flutterwave-payment-form, .flutterwave-donation-form {
- /*position: absolute;*/
- background: #FFFFFF;
- border: 1px solid rgba(18, 18, 44, 0.1);
- box-shadow: 2px 6px 20px rgba(18, 18, 44, 0.07);
- border-radius: 8px;
- padding: 30px;
- width: 400px;
- font-family: "Source Sans Pro","HelveticaNeue-Light","Helvetica Neue Light","Helvetica Neue",Helvetica,Arial,"Lucida Grande",sans-serif;
- margin: auto;
-}
-
-.flutterwave-payment-form > form, .flutterwave-donation-form > form {
- width: 100%;
- display: flex;
- flex-direction: column;
- justify-content: center;
+.flutterwave-payment-form form,
+.flutterwave-donation-form form {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ width: 100%;
+}
+
+/* Header ------------------------------------------------------------------ */
+
+.flutterwave-payment-form .flw-form-header,
+.flutterwave-donation-form .flw-form-header {
+ margin-bottom: 24px;
+}
+
+.flutterwave-payment-form .flw-form-title,
+.flutterwave-donation-form .flw-form-title {
+ color: var(--flw-ink);
+ font-family: var(--flw-serif);
+ font-size: 26px;
+ font-weight: 400;
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+ margin: 0 0 6px;
+}
+
+.flutterwave-payment-form .flw-form-description,
+.flutterwave-donation-form .flw-form-description {
+ color: var(--flw-body);
+ margin: 0;
+}
+
+/* Fields ------------------------------------------------------------------ */
+
+.flutterwave-payment-form label,
+.flutterwave-donation-form label {
+ color: var(--flw-ink);
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ margin: 0 0 6px;
+}
+
+.flutterwave-payment-form .flw-form-input-text,
+.flutterwave-payment-form .flw-form-select,
+.flutterwave-donation-form .flw-form-input-text,
+.flutterwave-donation-form .flw-form-select {
+ appearance: none;
+ background-color: #fff;
+ border: 1px solid var(--flw-border);
+ border-radius: var(--flw-radius);
+ box-shadow: none;
+ color: var(--flw-ink);
+ display: block;
+ font-family: inherit;
+ font-size: 15px;
+ height: auto;
+ line-height: 1.4;
+ margin: 0 0 18px;
+ max-width: none;
+ min-height: 48px;
+ padding: 12px 14px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ width: 100%;
+}
+
+.flutterwave-payment-form .flw-form-select,
+.flutterwave-donation-form .flw-form-select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%23475467' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-position: right 14px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ cursor: pointer;
+ padding-right: 42px;
+}
+
+.flutterwave-payment-form .flw-form-input-text::placeholder,
+.flutterwave-donation-form .flw-form-input-text::placeholder {
+ color: var(--flw-muted);
+ opacity: 1;
+}
+
+.flutterwave-payment-form .flw-form-input-text:hover,
+.flutterwave-payment-form .flw-form-select:hover,
+.flutterwave-donation-form .flw-form-input-text:hover,
+.flutterwave-donation-form .flw-form-select:hover {
+ border-color: var(--flw-muted);
+}
+
+.flutterwave-payment-form .flw-form-input-text:focus,
+.flutterwave-payment-form .flw-form-select:focus,
+.flutterwave-donation-form .flw-form-input-text:focus,
+.flutterwave-donation-form .flw-form-select:focus {
+ border-color: var(--flw-orange);
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.18);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--flw-orange) 22%, transparent);
+ outline: none;
+}
+
+/* Amount summary ---------------------------------------------------------- */
+
+.flutterwave-payment-form .flw_payment_overview {
+ align-items: center;
+ background: #fff6e9;
+ background: color-mix(in srgb, var(--flw-orange) 9%, #fff);
+ border-radius: var(--flw-radius);
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+ margin: 0 0 20px;
+ padding: 14px 16px;
+}
+
+.flutterwave-payment-form .flw_total_label {
+ color: var(--flw-body);
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+.flutterwave-payment-form .flw_amount_to_pay {
+ color: var(--flw-ink);
+ font-family: var(--flw-serif);
+ font-size: clamp(18px, 5vw, 24px);
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+/* Button ------------------------------------------------------------------ */
+
+.flutterwave-payment-form .flw-pay-now-button,
+.flutterwave-donation-form .flw-pay-now-button {
+ align-items: center;
+ background: var(--flw-orange);
+ border: 0;
+ border-radius: var(--flw-radius);
+ box-shadow: none;
+ color: var(--flw-on-orange);
+ cursor: pointer;
+ display: flex;
+ font-family: inherit;
+ font-size: 16px;
+ font-weight: 500;
+ gap: 10px;
+ justify-content: center;
+ letter-spacing: normal;
+ line-height: 1.2;
+ margin: 6px 0 0;
+ min-height: 50px;
+ padding: 0 24px;
+ text-shadow: none;
+ text-transform: none;
+ transition: background-color 0.15s ease;
+ width: 100%;
+}
+
+.flutterwave-payment-form .flw-pay-now-button:hover:not(:disabled),
+.flutterwave-donation-form .flw-pay-now-button:hover:not(:disabled) {
+ background: var(--flw-orange);
+ color: var(--flw-on-orange);
+ filter: brightness(0.93);
+}
+
+.flutterwave-payment-form .flw-pay-now-button:focus-visible,
+.flutterwave-donation-form .flw-pay-now-button:focus-visible {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.35);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--flw-orange) 40%, transparent);
+ outline: none;
+}
+
+.flutterwave-payment-form .flw-pay-now-button:disabled,
+.flutterwave-donation-form .flw-pay-now-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+/* Messages ---------------------------------------------------------------- */
+
+.flutterwave-payment-form .flw-error,
+.flutterwave-donation-form .flw-error {
+ background: #fef3f2;
+ border: 1px solid #fecdca;
+ border-radius: var(--flw-radius);
+ color: #b42318;
+ display: none;
+ font-size: 14px;
+ margin: 0 0 18px;
+ padding: 10px 14px;
+}
+
+.flutterwave-payment-form .flw-error.is-visible,
+.flutterwave-donation-form .flw-error.is-visible {
+ display: block;
+}
+
+.flutterwave-payment-form #notice:empty,
+.flutterwave-donation-form #notice:empty {
+ display: none;
+}
+
+.flutterwave-payment-form .flw-secured,
+.flutterwave-donation-form .flw-secured {
+ align-items: center;
+ color: var(--flw-muted);
+ display: flex;
+ font-size: 12px;
+ gap: 6px;
+ justify-content: center;
+ margin: 18px 0 0;
+}
+
+.flutterwave-payment-form .flw-secured strong,
+.flutterwave-donation-form .flw-secured strong {
+ color: var(--flw-body);
+ font-weight: 500;
+}
+
+/* Donation amount presets ------------------------------------------------ */
+
+.flutterwave-donation-form .flw-amount-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 12px;
+}
+
+.flutterwave-donation-form .flw-amount-preset {
+ background: #fff;
+ border: 1px solid var(--flw-border);
+ border-radius: 999px;
+ box-shadow: none;
+ color: var(--flw-ink);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.2;
+ margin: 0;
+ padding: 9px 16px;
+ text-transform: none;
+}
+
+.flutterwave-donation-form .flw-amount-preset:hover {
+ border-color: var(--flw-muted);
+}
+
+.flutterwave-donation-form .flw-amount-preset.is-selected {
+ background: #fff6e9;
+ background: color-mix(in srgb, var(--flw-orange) 12%, #fff);
+ border-color: var(--flw-orange);
+}
+
+/* Compact layout (payment button, pricing card) ---------------------------- */
+
+.flutterwave-payment-form.flw-layout-compact {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ margin: 0;
+ max-width: none;
+ padding: 0;
+}
+
+.flutterwave-payment-form.flw-layout-compact form {
+ align-items: stretch;
+ flex-flow: row wrap;
+ gap: 8px;
+}
+
+.flutterwave-payment-form.flw-layout-compact label,
+.flutterwave-payment-form.flw-layout-compact .flw_payment_overview {
+ border: 0;
+ clip-path: inset(50%);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
+
+.flutterwave-payment-form.flw-layout-compact .flw-form-input-text,
+.flutterwave-payment-form.flw-layout-compact .flw-form-select {
+ flex: 1 1 200px;
+ margin: 0;
+ width: auto;
+}
+
+.flutterwave-payment-form.flw-layout-compact .flw-pay-now-button {
+ flex: 0 0 auto;
+ margin: 0;
+ width: auto;
+}
+
+.flutterwave-payment-form.flw-layout-compact.flw-width-full form {
+ flex-direction: column;
+}
+
+.flutterwave-payment-form.flw-layout-compact.flw-width-full .flw-form-input-text,
+.flutterwave-payment-form.flw-layout-compact.flw-width-full .flw-form-select {
+
+ /* In a column the 200px basis would become the field's height. */
+ flex: 0 0 auto;
+ width: 100%;
+}
+
+.flutterwave-payment-form.flw-layout-compact.flw-width-full .flw-pay-now-button {
+ width: 100%;
+}
+
+.flutterwave-payment-form.flw-layout-compact .flw-secured {
+ justify-content: flex-start;
+ margin-top: 10px;
+}
+
+.flutterwave-payment-form.flw-layout-compact.flw-width-full .flw-secured {
+ justify-content: center;
+}
+
+/* Pricing card ------------------------------------------------------------ */
+
+.flw-pricing-card {
+ --flw-card-accent: var(--flw-accent, #ff9b00);
+
+ background: #fff;
+ border: 1px solid #eaecf0;
+ border-radius: 12px;
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 12px 32px -12px rgba(16, 24, 40, 0.12);
+ box-sizing: border-box;
+ color: #101828;
+ display: flex;
+ flex-direction: column;
+ font-family: Moderat, Inter, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, sans-serif;
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 24px auto;
+ max-width: 360px;
+ padding: 28px;
+ position: relative;
+ width: 100%;
+}
+
+.flw-pricing-card.is-highlighted {
+ border: 2px solid var(--flw-card-accent);
+}
+
+.flw-pricing-card__badge {
+ background: var(--flw-card-accent);
+ border-radius: 999px;
+ color: var(--flw-accent-text, #1a1a1a);
+ font-size: 12px;
+ font-weight: 500;
+ padding: 4px 10px;
+ position: absolute;
+ right: 20px;
+ top: -12px;
+}
+
+.flw-pricing-card__name {
+ color: #475467;
+ font-size: 15px;
+ font-weight: 500;
+ margin: 0 0 8px;
+}
+
+.flw-pricing-card__price {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 0 0 12px;
+}
+
+.flw-pricing-card__amount {
+ font-family: Millik, "Iowan Old Style", "Palatino Linotype", palatino, georgia, serif;
+ font-size: 38px;
+ line-height: 1.1;
+}
+
+.flw-pricing-card__period {
+ color: #98a2b3;
+ font-size: 14px;
+}
+
+.flw-pricing-card__description {
+ color: #475467;
+ margin: 0 0 16px;
+}
+
+.flw-pricing-card__features {
+ border-top: 1px solid #eaecf0;
+ list-style: none;
+ margin: 0 0 24px;
+ padding: 16px 0 0;
+}
+
+.flw-pricing-card__features li {
+ align-items: flex-start;
+ display: flex;
+ gap: 10px;
+ margin: 0 0 10px;
+}
+
+.flw-pricing-card__features svg {
+ color: var(--flw-card-accent);
+ flex: 0 0 auto;
+ margin-top: 4px;
}
-.flutterwave-payment-form label, .flutterwave-donation-form label {
- /*font-family: Inter;*/
- font-size: 16px;
- font-weight: 400;
- line-height: 20px;
- letter-spacing: 0px;
- text-align: left;
+.flw-pricing-card .flutterwave-payment-form {
+ margin-top: auto;
}
-.flutterwave-payment-form ::placeholder, .flutterwave-donation-form ::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */
- font-size: 16px;
- font-weight: 400;
- line-height: 20px;
- letter-spacing: 0px;
- text-align: left;
- opacity: 1; /* Firefox */
-}
-
-.flutterwave-payment-form :-ms-input-placeholder, .flutterwave-donation-form :-ms-input-placeholder { /* Internet Explorer 10-11 */
- font-size: 16px;
- font-weight: 400;
- line-height: 20px;
- letter-spacing: 0px;
- text-align: left;
-}
-
-.flutterwave-payment-form ::-ms-input-placeholder, .flutterwave-donation-form ::-ms-input-placeholder { /* Microsoft Edge */
- font-size: 16px;
- font-weight: 400;
- line-height: 20px;
- letter-spacing: 0px;
- text-align: left;
-}
-
-.flutterwave-payment-form input[type="text"],
-.flutterwave-payment-form input[type="number"],
-.flutterwave-payment-form input[type="tel"],
-.flutterwave-payment-form input[type="email"], .flutterwave-payment-form select {
- background: #FFFFFF;
- border: 1px solid #BDBDBD;
- /*border: 1px solid #F5A623;*/
- border-radius: 4px;
- padding: 15px;
- font-size: 16px;
- font-weight: 400;
- display: flex;
- justify-content: flex-start;
- margin-bottom: .9em;
-}
-
-
-#flw-first-name:hover, #flw-customer-email:hover, #flw-last-name:hover, #flw-amount:hover, #flw-full-name:hover, #flw-phone:hover{
- border: 1px solid #F5A623;
-}
-
-.flutterwave-payment-form form #flw-pay-now-button, .flutterwave-donation-form form #flw-pay-now-button {
- background: #F5A623;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- height: 56px;
- color: #000000;
- border-radius: 4px;
-}
-
-.flutterwave-payment-form form #flw-pay-now-button:disabled, .flutterwave-donation-form form #flw-pay-now-button:disabled {
- background-color: rgba(239, 239, 239, 0.3);
- color: rgba(16, 16, 16, 0.3);
- border-color: rgba(118, 118, 118, 0.3);
-}
-
-.flutterwave-payment-form form #flw-pay-now-button:hover, .flutterwave-donation-form form #flw-pay-now-button:hover {
- background: #6672c7;
- color: #FFFFFF;
+/* Accepted payment methods ------------------------------------------------ */
+
+.flw-payment-methods {
+ color: #475467;
+ font-family: Moderat, Inter, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, sans-serif;
+ font-size: 14px;
+}
+
+.flw-payment-methods__label {
+ color: #101828;
+ font-weight: 500;
+ margin: 0 0 8px;
+}
+
+.flw-payment-methods__list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.flw-payment-methods__item {
+ align-items: center;
+ background: #fff;
+ border: 1px solid #eaecf0;
+ border-radius: 999px;
+ color: #101828;
+ display: inline-flex;
+ font-size: 13px;
+ gap: 6px;
+ margin: 0;
+ padding: 6px 12px;
+}
+
+.flw-payment-methods__item svg {
+ color: #475467;
+}
+
+.flw-payment-methods .flw-secured {
+ align-items: center;
+ color: #98a2b3;
+ display: flex;
+ font-size: 12px;
+ gap: 6px;
+ margin: 12px 0 0;
+}
+
+.flw-payment-methods .flw-secured strong {
+ color: #475467;
+ font-weight: 500;
+}
+
+/* Block alignment --------------------------------------------------------- */
+
+.wp-block-flutterwave-payment-form.alignleft > *,
+.wp-block-flutterwave-donation-form.alignleft > *,
+.wp-block-flutterwave-pricing-card.alignleft > * {
+ margin-left: 0;
+}
+
+.wp-block-flutterwave-payment-form.alignright > *,
+.wp-block-flutterwave-donation-form.alignright > *,
+.wp-block-flutterwave-pricing-card.alignright > * {
+ margin-right: 0;
}
-.flutterwave-payment-form form > div.flw_payment_overview {
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
+.wp-block-flutterwave-payment-button.aligncenter .flw-layout-compact form,
+.wp-block-flutterwave-payment-methods.aligncenter .flw-payment-methods__list {
+ justify-content: center;
}
-.flutterwave-payment-form form > div.flw_payment_overview > div.flw_total_label {
- font-size: 16px;
- font-weight: 400;
+.wp-block-flutterwave-payment-methods.aligncenter {
+ text-align: center;
}
-.flutterwave-payment-form form > div.flw_payment_overview > div.flw_amount_to_pay {
- font-size: 22px;
- font-weight: 600;
- color: #35a162;
+.wp-block-flutterwave-payment-methods.aligncenter .flw-secured {
+ justify-content: center;
}
-.flutterwave-payment-form div, .flutterwave-donation-form div {
- display: block;
+/* Shown to editors while the plugin still needs setting up. */
+.flw-mssing-api-keys {
+ background: #fff6e9;
+ border-left: 4px solid #ff9b00;
+ border-radius: 8px;
+ color: #7a4a00;
+ display: block;
+ font-family: Moderat, Inter, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, sans-serif;
+ font-size: 15px;
+ margin: 24px auto;
+ max-width: 440px;
+ padding: 14px 16px;
+}
+
+.flw-mssing-api-keys a {
+ color: inherit;
+ font-weight: 500;
+}
+
+/* Redirect overlay -------------------------------------------------------- */
+
+.flutterwave-overlay {
+ backdrop-filter: blur(2px);
+ background-color: rgba(16, 24, 40, 0.55);
+ bottom: 0;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 99999;
+}
+
+.flw-overlay-text {
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 20px 48px rgba(16, 24, 40, 0.24);
+ color: #101828;
+ font-family: Moderat, Inter, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, sans-serif;
+ font-size: 15px;
+ left: 50%;
+ line-height: 1.5;
+ max-width: calc(100% - 32px);
+ padding: 28px 32px;
+ position: absolute;
+ text-align: center;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 360px;
+}
+
+.flw-overlay-spinner {
+ animation: flw-spin 0.9s linear infinite;
+ border: 3px solid #f2f4f7;
+ border-radius: 50%;
+ border-top-color: #ff9b00;
+ display: block;
+ height: 40px;
+ margin: 0 auto 16px;
+ width: 40px;
+}
+
+@keyframes flw-spin {
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+
+ .flw-overlay-spinner {
+ animation-duration: 3s;
+ }
}
-/** Flutterwave Donation Page **/
+@media screen and (max-width: 480px) {
-.flutterwave-donation-form input[type="text"],
-.flutterwave-donation-form input[type="number"],
-.flutterwave-donation-form input[type="email"], .flutterwave-donation-form select {
- background: #FFFFFF;
- border: 1px solid #BDBDBD;
- /*border: 1px solid #F5A623;*/
- border-radius: 4px;
- padding: 15px;
- font-size: 16px;
- font-weight: 400;
- display: flex;
- justify-content: flex-start;
+ .flutterwave-payment-form,
+ .flutterwave-donation-form {
+ border-radius: 10px;
+ padding: 24px 20px;
+ }
}
diff --git a/assets/fonts/Inter-cyrillic-ext-normal.woff2 b/assets/fonts/Inter-cyrillic-ext-normal.woff2
new file mode 100644
index 0000000..405017d
Binary files /dev/null and b/assets/fonts/Inter-cyrillic-ext-normal.woff2 differ
diff --git a/assets/fonts/Inter-cyrillic-normal.woff2 b/assets/fonts/Inter-cyrillic-normal.woff2
new file mode 100644
index 0000000..f57ab2f
Binary files /dev/null and b/assets/fonts/Inter-cyrillic-normal.woff2 differ
diff --git a/assets/fonts/Inter-greek-ext-normal.woff2 b/assets/fonts/Inter-greek-ext-normal.woff2
new file mode 100644
index 0000000..2fc07e9
Binary files /dev/null and b/assets/fonts/Inter-greek-ext-normal.woff2 differ
diff --git a/assets/fonts/Inter-greek-normal.woff2 b/assets/fonts/Inter-greek-normal.woff2
new file mode 100644
index 0000000..41576da
Binary files /dev/null and b/assets/fonts/Inter-greek-normal.woff2 differ
diff --git a/assets/fonts/Inter-latin-ext-normal.woff2 b/assets/fonts/Inter-latin-ext-normal.woff2
new file mode 100644
index 0000000..fb2522c
Binary files /dev/null and b/assets/fonts/Inter-latin-ext-normal.woff2 differ
diff --git a/assets/fonts/Inter-latin-normal.woff2 b/assets/fonts/Inter-latin-normal.woff2
new file mode 100644
index 0000000..937631c
Binary files /dev/null and b/assets/fonts/Inter-latin-normal.woff2 differ
diff --git a/assets/fonts/Inter-vietnamese-normal.woff2 b/assets/fonts/Inter-vietnamese-normal.woff2
new file mode 100644
index 0000000..e905732
Binary files /dev/null and b/assets/fonts/Inter-vietnamese-normal.woff2 differ
diff --git a/assets/fonts/Millik.woff2 b/assets/fonts/Millik.woff2
new file mode 100644
index 0000000..ce3671b
Binary files /dev/null and b/assets/fonts/Millik.woff2 differ
diff --git a/assets/fonts/Moderat-Bold.woff2 b/assets/fonts/Moderat-Bold.woff2
new file mode 100644
index 0000000..f44b8a3
Binary files /dev/null and b/assets/fonts/Moderat-Bold.woff2 differ
diff --git a/assets/fonts/Moderat-Medium.woff2 b/assets/fonts/Moderat-Medium.woff2
new file mode 100644
index 0000000..d655c1a
Binary files /dev/null and b/assets/fonts/Moderat-Medium.woff2 differ
diff --git a/assets/fonts/Moderat-Regular.woff2 b/assets/fonts/Moderat-Regular.woff2
new file mode 100644
index 0000000..dc9dd31
Binary files /dev/null and b/assets/fonts/Moderat-Regular.woff2 differ
diff --git a/assets/images/admin/flutterwave-logo.svg b/assets/images/admin/flutterwave-logo.svg
new file mode 100644
index 0000000..1249d98
--- /dev/null
+++ b/assets/images/admin/flutterwave-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/admin/welcome-docs.svg b/assets/images/admin/welcome-docs.svg
new file mode 100644
index 0000000..a26766b
--- /dev/null
+++ b/assets/images/admin/welcome-docs.svg
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/images/admin/welcome-support.svg b/assets/images/admin/welcome-support.svg
new file mode 100644
index 0000000..d5874de
--- /dev/null
+++ b/assets/images/admin/welcome-support.svg
@@ -0,0 +1,373 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/images/bg.jpg b/assets/images/bg.jpg
deleted file mode 100644
index 0859b52..0000000
Binary files a/assets/images/bg.jpg and /dev/null differ
diff --git a/assets/images/bg2.jpg b/assets/images/bg2.jpg
deleted file mode 100644
index c060eb6..0000000
Binary files a/assets/images/bg2.jpg and /dev/null differ
diff --git a/assets/images/donation-loading.gif b/assets/images/donation-loading.gif
deleted file mode 100644
index 88f6fb1..0000000
Binary files a/assets/images/donation-loading.gif and /dev/null differ
diff --git a/assets/images/flutterwave-full.svg b/assets/images/flutterwave-full.svg
deleted file mode 100644
index e31cd8d..0000000
--- a/assets/images/flutterwave-full.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/images/flutterwave-icon.png b/assets/images/flutterwave-icon.png
deleted file mode 100644
index 8e13c00..0000000
Binary files a/assets/images/flutterwave-icon.png and /dev/null differ
diff --git a/assets/images/flutterwave-logo-flat-inverted.svg b/assets/images/flutterwave-logo-flat-inverted.svg
deleted file mode 100644
index 7ea2334..0000000
--- a/assets/images/flutterwave-logo-flat-inverted.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/images/flutterwave.png b/assets/images/flutterwave.png
deleted file mode 100644
index b76c2d8..0000000
Binary files a/assets/images/flutterwave.png and /dev/null differ
diff --git a/assets/images/flw-lock.png b/assets/images/flw-lock.png
deleted file mode 100644
index d38a502..0000000
Binary files a/assets/images/flw-lock.png and /dev/null differ
diff --git a/assets/images/flw-lock2.png b/assets/images/flw-lock2.png
deleted file mode 100644
index 5462910..0000000
Binary files a/assets/images/flw-lock2.png and /dev/null differ
diff --git a/assets/images/flw-lock4.png b/assets/images/flw-lock4.png
deleted file mode 100644
index 4c5a64f..0000000
Binary files a/assets/images/flw-lock4.png and /dev/null differ
diff --git a/assets/images/old-logo.svg b/assets/images/old-logo.svg
deleted file mode 100644
index df9d909..0000000
--- a/assets/images/old-logo.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/assets/images/plus.svg b/assets/images/plus.svg
deleted file mode 100644
index 2f7efd4..0000000
--- a/assets/images/plus.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/images/rave-icon.jpg b/assets/images/rave-icon.jpg
deleted file mode 100644
index ea6e7c5..0000000
Binary files a/assets/images/rave-icon.jpg and /dev/null differ
diff --git a/assets/js/admin/integrations.js b/assets/js/admin/integrations.js
index 59ff815..d9e7673 100644
--- a/assets/js/admin/integrations.js
+++ b/assets/js/admin/integrations.js
@@ -1,3 +1,3 @@
-jQuery(function ($) {
- $('body').addClass('.flutterwave-page');
-});
+jQuery( function ( $ ) {
+ $( 'body' ).addClass( '.flutterwave-page' );
+} );
diff --git a/assets/js/flw-donation.js b/assets/js/flw-donation.js
index 7abdd0f..ee87a74 100644
--- a/assets/js/flw-donation.js
+++ b/assets/js/flw-donation.js
@@ -1,134 +1,206 @@
-var pp;
+jQuery( function ( $ ) {
+ const options = window.flw_pay_options;
-jQuery(function ($) {
/**
- * Builds config object to be sent to GetPaid
+ * Reads a data-* attribute as a raw string.
*
- * @return object - The config object
+ * @param {jQuery} form The form element.
+ * @param {string} name Attribute name without the data- prefix.
+ * @return {string|undefined} The attribute value.
*/
- const buildConfigObj = function (form) {
- let formData = $(form).data();
- let amount = formData.amount?.replace(/"|'/g, '') || $(form).find('#flw-amount').val();
- let email = formData.email?.replace(/"|'/g, '') || $(form).find('#flw-customer-email').val();
- let firstname =
- formData.firstname?.replace(/"|'/g, '') || $(form).find('#flw-first-name').val();
- let lastname =
- formData.lastname?.replace(/"|'/g, '') || $(form).find('#flw-last-name').val();
- let formCurrency =
- formData.currency?.replace(/"|'/g, '') || $(form).find('#flw-currency').val();
- let formId = form.attr('id');
- let txref = 'WP_' + formId.toUpperCase() + '_' + new Date().valueOf();
- let setCountry; //set country
-
- //switch the country with form currency provided
- setCountry = flw_pay_options.countries[formCurrency]
- ? flw_pay_options.countries[formCurrency]
- : flw_pay_options.countries['NGN'];
-
- let redirect_url = window.location.origin;
+ const readAttr = function ( form, name ) {
+ const value = $( form ).attr( 'data-' + name );
+ return value === undefined || value === '' ? undefined : value;
+ };
+
+ /**
+ * The fixed amount from data-amount, or the amount the customer typed.
+ *
+ * @param {jQuery} form The form element.
+ * @return {string|undefined} The amount to charge.
+ */
+ const amountFor = function ( form ) {
+ const fixed = readAttr( form, 'amount' );
+
+ // "0" or an empty attribute means the customer chooses the amount.
+ if ( fixed !== undefined && parseFloat( fixed ) > 0 ) {
+ return fixed;
+ }
+
+ return $( form ).find( '#flw-amount' ).val();
+ };
+
+ /**
+ * Shows a checkout error.
+ *
+ * @param {jQuery} form The form element.
+ * @param {string} message The message to show.
+ */
+ const showError = function ( form, message ) {
+ errorBox( form ).text( message ).addClass( 'is-visible' );
+ $( form ).find( 'button' ).prop( 'disabled', false );
+ };
+
+ /**
+ * The error box belonging to a form.
+ *
+ * @param {jQuery} form The form element.
+ * @return {jQuery} The error box.
+ */
+ const errorBox = function ( form ) {
+ return $( form )
+ .closest( '.flutterwave-payment-form, .flutterwave-donation-form' )
+ .find( '.flw-error' );
+ };
+
+ /**
+ * Builds the checkout request.
+ *
+ * @param {jQuery} form The form element.
+ * @return {Object} The checkout request.
+ */
+ const buildConfigObj = function ( form ) {
+ const amount = amountFor( form );
+ const email =
+ readAttr( form, 'email' ) ||
+ $( form ).find( '#flw-customer-email' ).val();
+ const firstname =
+ readAttr( form, 'firstname' ) ||
+ $( form ).find( '#flw-first-name' ).val();
+ const lastname =
+ readAttr( form, 'lastname' ) ||
+ $( form ).find( '#flw-last-name' ).val();
+ const formCurrency =
+ readAttr( form, 'currency' ) ||
+ $( form ).find( '#flw-currency' ).val();
+ const formId = form.attr( 'id' );
+ const txref = 'WP_' + formId.toUpperCase() + '_' + new Date().valueOf();
+ // Switch the country with the form currency provided.
+ const country = options.countries[ formCurrency ]
+ ? options.countries[ formCurrency ]
+ : options.countries.NGN;
return {
- amount: amount,
- country: setCountry, //flw_pay_options.country,
- currency: formCurrency ?? flw_pay_options.currency,
+ amount,
+ country,
+ currency: formCurrency ?? options.currency,
customer: {
email,
phone_number: null,
name: firstname + ' ' + lastname,
},
- payment_options: flw_pay_options.method,
- public_key: flw_pay_options.public_key,
+ payment_options: options.method,
+ public_key: options.public_key,
tx_ref: txref,
customizations: {
- title: flw_pay_options.title,
- description: flw_pay_options.desc,
- logo: flw_pay_options.logo,
+ title: options.title,
+ description: options.desc,
+ logo: options.logo,
},
form_id: formId,
};
};
- const processCheckout = function (opts, form) {
- let args = {
+ const processCheckout = function ( opts, form ) {
+ const args = {
action: 'get_payment_url',
- flw_sec_code: $(form).find('#flw_sec_code').val(),
- payment_type: $(form).find('#flw-payment-type').val(),
+ flw_sec_code: $( form ).find( '#flw_sec_code' ).val(),
+ payment_type: $( form ).find( '#flw-payment-type' ).val(),
+ flw_form_config: $( form )
+ .find( 'input[name="flw_form_config"]' )
+ .val(),
+ flw_form_sig: $( form ).find( 'input[name="flw_form_sig"]' ).val(),
};
- let dataObj = Object.assign({}, args, opts);
- $.post(flw_pay_options.cb_url, dataObj).success(function (data) {
- let response = data;
+ const dataObj = Object.assign( {}, args, opts );
+ $.post( options.cb_url, dataObj )
+ .done( function ( data ) {
+ const response = data;
- if (response.status === 'error') {
- $('.flw-error')
- .html(response.message)
- .attr('style', 'color:red');
- } else {
- let flw_overlay = $('#flutterwave-overlay');
- flw_overlay.addClass('flutterwave-overlay');
- $('#flw-overlay-text').addClass('flw-overlay-text');
- flw_overlay.show();
- redirectTo(response.url);
- }
- });
+ if ( response.status === 'error' ) {
+ showError( form, response.message );
+ } else {
+ const overlay = $( '#flutterwave-overlay' );
+ overlay.addClass( 'flutterwave-overlay' );
+ $( '#flw-overlay-text' ).addClass( 'flw-overlay-text' );
+ overlay.show();
+ redirectTo( response.url );
+ }
+ } )
+ .fail( function ( xhr ) {
+ showError(
+ form,
+ ( xhr.responseJSON && xhr.responseJSON.message ) ||
+ 'Unable to start the payment. Please try again.'
+ );
+ } );
};
/**
- * Redirect to set url
- *
- * @param string url - The link to redirect to
+ * Redirect to a url.
*
- * @return void
+ * @param {string} url The link to redirect to.
*/
- const redirectTo = function (url) {
- if (url) {
+ const redirectTo = function ( url ) {
+ if ( url ) {
location.href = url;
}
};
// for each form process payments
- $('.flw-donation-form').each(function () {
- let form = $(this);
-
- form.find('#flw-payment-type').on('change', function () {
- let option = $(this).val();
- let btn = jQuery('.flw-donation-form').find('#flw-pay-now-button');
- let fullText = 'DONATE ' + option.toUpperCase();
- btn.text(null);
- console.log(btn.text());
- btn.text(fullText);
- });
-
- form.on('submit', function (event) {
+ $( '.flw-donation-form' ).each( function () {
+ const form = $( this );
+
+ form.find( '#flw-payment-type' ).on( 'change', function () {
+ const option = $( this ).val();
+ form.find( '#flw-pay-now-button' ).text( 'Donate ' + option );
+ } );
+
+ // Suggested amounts fill the amount field; typing clears the selection.
+ const presets = form.find( '.flw-amount-preset' );
+ presets.on( 'click', function () {
+ presets
+ .removeClass( 'is-selected' )
+ .attr( 'aria-pressed', 'false' );
+ $( this ).addClass( 'is-selected' ).attr( 'aria-pressed', 'true' );
+ form.find( '#flw-amount' ).val( $( this ).data( 'amount' ) );
+ } );
+ form.find( '#flw-amount' ).on( 'input', function () {
+ presets
+ .removeClass( 'is-selected' )
+ .attr( 'aria-pressed', 'false' );
+ } );
+
+ form.on( 'submit', function ( event ) {
event.preventDefault(); // Prevent the default form submission
- let btn = form.find('button');
- btn.prop('disabled', true);
+ errorBox( form ).removeClass( 'is-visible' ).text( '' );
+ const btn = form.find( 'button' );
+ btn.prop( 'disabled', true );
- let inputs = form.find('input[type="text"]');
+ const inputs = form.find( 'input[type="text"]' );
let isValid = true;
- inputs.each(function () {
- let inputValue = $(this).val();
+ inputs.each( function () {
+ const inputValue = $( this ).val();
if (
typeof inputValue === 'string' &&
inputValue.trim() === ''
) {
isValid = false;
- $(this).attr('style', 'border-color: red');
+ $( this ).attr( 'style', 'border-color: red' );
} else {
- $(this).attr('style', 'border-color: green');
+ $( this ).attr( 'style', 'border-color: green' );
}
- });
+ } );
- if (isValid) {
- let config = buildConfigObj(form);
- console.log(config);
- processCheckout(config, form);
+ if ( isValid ) {
+ const config = buildConfigObj( form );
+ processCheckout( config, form );
} else {
//unblur button.
- btn.effect('shake', { times: 2 }, 300);
- btn.prop('disabled', false);
+ btn.effect( 'shake', { times: 2 }, 300 );
+ btn.prop( 'disabled', false );
}
- });
- });
-});
+ } );
+ } );
+} );
diff --git a/assets/js/flw-donation.min.js b/assets/js/flw-donation.min.js
index 524b215..f2c4afe 100644
--- a/assets/js/flw-donation.min.js
+++ b/assets/js/flw-donation.min.js
@@ -1 +1 @@
-var pp;jQuery(function(f){function a(t,e){e={action:"get_payment_url",flw_sec_code:f(e).find("#flw_sec_code").val(),payment_type:f(e).find("#flw-payment-type").val()},e=Object.assign({},e,t),f.post(flw_pay_options.cb_url,e).success(function(t){if("error"===t.status)f(".flw-error").html(t.message).attr("style","color:red");else{var e=f("#flutterwave-overlay"),e=(e.addClass("flutterwave-overlay"),f("#flw-overlay-text").addClass("flw-overlay-text"),e.show(),t.url);if(e)location.href=e}})}f(".flw-donation-form").each(function(){let n=f(this);n.find("#flw-payment-type").on("change",function(){var t=f(this).val(),e=jQuery(".flw-donation-form").find("#flw-pay-now-button"),t="DONATE "+t.toUpperCase();e.text(null),console.log(e.text()),e.text(t)}),n.on("submit",function(t){t.preventDefault();var t=n.find("button"),e=(t.prop("disabled",!0),n.find('input[type="text"]'));let o=!0;e.each(function(){var t=f(this).val();"string"==typeof t&&""===t.trim()?(o=!1,f(this).attr("style","border-color: red")):f(this).attr("style","border-color: green")}),o?(e=function(t){var e=f(t).data(),o=e.amount?.replace(/"|'/g,"")||f(t).find("#flw-amount").val(),n=e.email?.replace(/"|'/g,"")||f(t).find("#flw-customer-email").val(),a=e.firstname?.replace(/"|'/g,"")||f(t).find("#flw-first-name").val(),l=e.lastname?.replace(/"|'/g,"")||f(t).find("#flw-last-name").val(),e=e.currency?.replace(/"|'/g,"")||f(t).find("#flw-currency").val(),t=t.attr("id"),r="WP_"+t.toUpperCase()+"_"+(new Date).valueOf(),i=flw_pay_options.countries[e]||flw_pay_options.countries.NGN;window.location.origin;return{amount:o,country:i,currency:e??flw_pay_options.currency,customer:{email:n,phone_number:null,name:a+" "+l},payment_options:flw_pay_options.method,public_key:flw_pay_options.public_key,tx_ref:r,customizations:{title:flw_pay_options.title,description:flw_pay_options.desc,logo:flw_pay_options.logo},form_id:t}}(n),console.log(e),a(e,n)):(t.effect("shake",{times:2},300),t.prop("disabled",!1))})})});
+jQuery(function(l){function a(t,e){r(t).text(e).addClass("is-visible"),l(t).find("button").prop("disabled",!1)}function o(t){n=t;var e=void 0!==(e=f(n,"amount"))&&0 0 ) {
+ return fixed;
+ }
+
+ return $( form ).find( '#flw-amount' ).val();
+ };
+
+ /**
+ * Shows a checkout error.
+ *
+ * @param {jQuery} form The form element.
+ * @param {string} message The message to show.
+ */
+ const showError = function ( form, message ) {
+ errorBox( form ).text( message ).addClass( 'is-visible' );
+ $( form ).find( 'button' ).prop( 'disabled', false );
+ };
+
/**
- * Builds config object to be sent to GetPaid
+ * The error box belonging to a form.
*
- * @return object - The config object
+ * @param {jQuery} form The form element.
+ * @return {jQuery} The error box.
*/
- const buildConfigObj = function (form) {
- let formData = $(form).data();
+ const errorBox = function ( form ) {
+ return $( form )
+ .closest( '.flutterwave-payment-form, .flutterwave-donation-form' )
+ .find( '.flw-error' );
+ };
+ /**
+ * Builds the checkout request.
+ *
+ * @param {jQuery} form The form element.
+ * @return {Object} The checkout request.
+ */
+ const buildConfigObj = function ( form ) {
// Form Appearance settings.
- let should_split_name = formData.split_name?.replace(/"|'/g, '') == 1;
+ const shouldSplitName = readAttr( form, 'split_name' ) === '1';
let fullname = '';
- if (!should_split_name) {
- fullname = $(form).find('#flw-full-name').val();
+ if ( ! shouldSplitName ) {
+ fullname =
+ readAttr( form, 'fullname' ) ||
+ $( form ).find( '#flw-full-name' ).val();
} else {
- let firstname =
- formData.firstname?.replace(/"|'/g, '') ||
- $(form).find('#flw-first-name').val();
- let lastname =
- formData.lastname?.replace(/"|'/g, '') ||
- $(form).find('#flw-last-name').val();
+ const firstname =
+ readAttr( form, 'firstname' ) ||
+ $( form ).find( '#flw-first-name' ).val();
+ const lastname =
+ readAttr( form, 'lastname' ) ||
+ $( form ).find( '#flw-last-name' ).val();
fullname = firstname + ' ' + lastname;
}
- let phone =
- formData.phone?.replace(/"|'/g, '') ||
- $(form).find('#flw-phone').val();
-
- let amount =
- formData.amount?.replace(/"|'/g, '') ||
- $(form).find('#flw-amount').val();
- let email =
- formData.email?.replace(/"|'/g, '') ||
- $(form).find('#flw-customer-email').val();
- let special_currency_value =
- formData.custom_currency?.replace(/"|'/g, '') ||
- $(form).find('#flw-currency').val();
- let formCurrency =
- formData.custom_currency?.replace(/"|'/g, '').length > 3
- ? $(form).find('#flw-currency').val()
- : special_currency_value;
-
- let formId = form.attr('id');
- let txref = 'WP_' + formId.toUpperCase() + '_' + new Date().valueOf();
- let setCountry; //set country
-
- //switch the country with form currency provided
- setCountry = flw_pay_options.countries[formCurrency]
- ? flw_pay_options.countries[formCurrency]
- : flw_pay_options.countries['NGN'];
-
- let redirect_url = window.location.origin;
+ const phone =
+ readAttr( form, 'phone' ) || $( form ).find( '#flw-phone' ).val();
+
+ const amount = amountFor( form );
+ const email =
+ readAttr( form, 'email' ) ||
+ $( form ).find( '#flw-customer-email' ).val();
+ const specialCurrencyValue =
+ readAttr( form, 'custom_currency' ) ||
+ $( form ).find( '#flw-currency' ).val();
+ const formCurrency =
+ ( readAttr( form, 'custom_currency' ) || '' ).length > 3
+ ? $( form ).find( '#flw-currency' ).val()
+ : specialCurrencyValue;
+
+ const formId = form.attr( 'id' );
+ const txref = 'WP_' + formId.toUpperCase() + '_' + new Date().valueOf();
+ // Switch the country with the form currency provided.
+ const country = options.countries[ formCurrency ]
+ ? options.countries[ formCurrency ]
+ : options.countries.NGN;
return {
- amount: amount,
- country: setCountry, //flw_pay_options.country,
- currency: formCurrency ?? flw_pay_options.currency,
+ amount,
+ country,
+ currency: formCurrency ?? options.currency,
customer: {
email,
phone_number: phone ?? null,
name: fullname,
},
- payment_options: flw_pay_options.method,
- public_key: flw_pay_options.public_key,
+ payment_options: options.method,
+ public_key: options.public_key,
tx_ref: txref,
customizations: {
- title: flw_pay_options.title,
- description: flw_pay_options.desc,
- logo: flw_pay_options.logo,
+ title: options.title,
+ description: options.desc,
+ logo: options.logo,
},
form_id: formId,
};
};
- const processCheckout = function (opts, form) {
- let args = {
+ const processCheckout = function ( opts, form ) {
+ const args = {
action: 'get_payment_url',
- flw_sec_code: $(form).find('#flw_sec_code').val(),
+ flw_sec_code: $( form ).find( '#flw_sec_code' ).val(),
+ flw_form_config: $( form )
+ .find( 'input[name="flw_form_config"]' )
+ .val(),
+ flw_form_sig: $( form ).find( 'input[name="flw_form_sig"]' ).val(),
};
- let dataObj = Object.assign({}, args, opts);
-
- // console.log(dataObj);
+ const dataObj = Object.assign( {}, args, opts );
- $.post(flw_pay_options.cb_url, dataObj).success(function (data) {
- let response = data;
+ $.post( options.cb_url, dataObj )
+ .done( function ( data ) {
+ const response = data;
- if (response.status === 'error') {
- $('.flw-error')
- .html(response.message)
- .attr('style', 'color:red');
- } else {
- let flw_overlay = $('#flutterwave-overlay');
- flw_overlay.addClass('flutterwave-overlay');
- $('#flw-overlay-text').addClass('flw-overlay-text');
- flw_overlay.show();
- redirectTo(response.url);
- }
- });
- };
-
- /**
- * Sends payment response from GetPaid to the process payment endpoint
- *
- * @param object Response object from GetPaid
- *
- * @return void
- */
- const sendPaymentRequestResponse = function (res, form) {
- let args = {
- action: 'process_payment',
- flw_sec_code: $(form).find('#flw_sec_code').val(),
- };
-
- let dataObj = Object.assign({}, args, res.tx);
-
- $.post(flw_pay_options.cb_url, dataObj).success(function (data) {
- var response = JSON.parse(data);
- redirectUrl = response.redirect_url;
-
- if (redirectUrl === '') {
- var responseMsg =
- res.tx.paymentType === 'account'
- ? res.tx.acctvalrespmsg
- : res.tx.vbvrespmessage;
- $(form)
- .find('#notice')
- .text(responseMsg)
- .removeClass(function () {
- return $(form).find('#notice').attr('class');
- })
- .addClass(response.status);
- } else {
- setTimeout(redirectTo, 5000, redirectUrl);
- }
- });
+ if ( response.status === 'error' ) {
+ showError( form, response.message );
+ } else {
+ const overlay = $( '#flutterwave-overlay' );
+ overlay.addClass( 'flutterwave-overlay' );
+ $( '#flw-overlay-text' ).addClass( 'flw-overlay-text' );
+ overlay.show();
+ redirectTo( response.url );
+ }
+ } )
+ .fail( function ( xhr ) {
+ showError(
+ form,
+ ( xhr.responseJSON && xhr.responseJSON.message ) ||
+ 'Unable to start the payment. Please try again.'
+ );
+ } );
};
/**
- * Redirect to set url
- *
- * @param string url - The link to redirect to
+ * Redirect to a url.
*
- * @return void
+ * @param {string} url The link to redirect to.
*/
- const redirectTo = function (url) {
- if (url) {
+ const redirectTo = function ( url ) {
+ if ( url ) {
location.href = url;
}
};
// for each form process payments
- $('.flw-simple-pay-now-form').each(function () {
- let form = $(this);
+ $( '.flw-simple-pay-now-form' ).each( function () {
+ const form = $( this );
- form.on('submit', function (event) {
+ form.on( 'submit', function ( event ) {
event.preventDefault(); // Prevent the default form submission
- let btn = form.find('button');
+ errorBox( form ).removeClass( 'is-visible' ).text( '' );
+ const btn = form.find( 'button' );
//gray the button.
- btn.prop('disabled', true);
+ btn.prop( 'disabled', true );
- let inputs = form.find('input[type="*"]');
+ const inputs = form.find( 'input[type="*"]' );
let isValid = true;
- inputs.each(function () {
- let inputValue = $(this).val();
+ inputs.each( function () {
+ const inputValue = $( this ).val();
if (
typeof inputValue === 'string' &&
inputValue.trim() === ''
) {
isValid = false;
- $(this).attr('style', 'border-color: red');
+ $( this ).attr( 'style', 'border-color: red' );
}
if (
- $(this).attr('type') === 'number' &&
- parseInt(inputValue) === NaN
+ $( this ).attr( 'type' ) === 'number' &&
+ Number.isNaN( parseInt( inputValue, 10 ) )
) {
isValid = false;
- $(this).attr('style', 'border-color: red');
+ $( this ).attr( 'style', 'border-color: red' );
}
- });
+ } );
- if (isValid) {
- let config = buildConfigObj(form);
- console.log(config);
- processCheckout(config, form);
+ if ( isValid ) {
+ const config = buildConfigObj( form );
+ processCheckout( config, form );
} else {
//unblur button.
- btn.effect('shake', { times: 2 }, 300);
- btn.prop('disabled', false);
+ btn.effect( 'shake', { times: 2 }, 300 );
+ btn.prop( 'disabled', false );
}
- });
- });
-});
+ } );
+ } );
+} );
diff --git a/assets/js/flw.min.js b/assets/js/flw.min.js
index b9e51b3..86a5ce3 100644
--- a/assets/js/flw.min.js
+++ b/assets/js/flw.min.js
@@ -1 +1 @@
-jQuery(function(s){function n(e){e&&(location.href=e)}s(".flw-simple-pay-now-form").each(function(){let o=s(this);o.on("submit",function(e){e.preventDefault();var t,e=o.find("button"),l=(e.prop("disabled",!0),o.find('input[type="*"]'));let a=!0;l.each(function(){var e=s(this).val();"string"==typeof e&&""===e.trim()&&(a=!1,s(this).attr("style","border-color: red")),"number"===s(this).attr("type")&&NaN===parseInt(e)&&(a=!1,s(this).attr("style","border-color: red"))}),a?(l=function(e){var t=s(e).data(),l=1==t.split_name?.replace(/"|'/g,"");let a="";a=l?(t.firstname?.replace(/"|'/g,"")||s(e).find("#flw-first-name").val())+" "+(t.lastname?.replace(/"|'/g,"")||s(e).find("#flw-last-name").val()):s(e).find("#flw-full-name").val();var l=t.phone?.replace(/"|'/g,"")||s(e).find("#flw-phone").val(),o=t.amount?.replace(/"|'/g,"")||s(e).find("#flw-amount").val(),n=t.email?.replace(/"|'/g,"")||s(e).find("#flw-customer-email").val(),r=t.custom_currency?.replace(/"|'/g,"")||s(e).find("#flw-currency").val(),t=3{if(window.givewp&&window.givewp.gateways){let e={id:"flutterwave",Fields(){return e.settings&&e.settings.message||""}};window.givewp.gateways.register(e)}})();
diff --git a/assets/js/paymentplan.js b/assets/js/paymentplan.js
index e979c36..6f308e6 100644
--- a/assets/js/paymentplan.js
+++ b/assets/js/paymentplan.js
@@ -1,23 +1,23 @@
-const planName = jQuery('#plan-name');
-const planAmount = jQuery('#plan-amount');
-const planInterval = jQuery('#plan-interval');
-const planDuration = jQuery('#plan-duration');
+const planName = jQuery( '#plan-name' );
+const planAmount = jQuery( '#plan-amount' );
+const planInterval = jQuery( '#plan-interval' );
+const planDuration = jQuery( '#plan-duration' );
-jQuery(document).ready(function () {
- jQuery('#wpfooter').hide();
+jQuery( document ).ready( function () {
+ jQuery( '#wpfooter' ).hide();
- jQuery('#createPlan-btn').on('click', (event) => {
+ jQuery( '#createPlan-btn' ).on( 'click', ( event ) => {
event.preventDefault();
jQuery
- .post('#', {
+ .post( '#', {
amount: planAmount.val(),
name: planName.val(),
interval: planInterval.val(),
- duration: parseInt(planDuration.val()),
- })
- .done(function () {
+ duration: parseInt( planDuration.val() ),
+ } )
+ .done( function () {
location.reload();
- });
- });
-});
+ } );
+ } );
+} );
diff --git a/assets/js/paymentplan.min.js b/assets/js/paymentplan.min.js
index 86f0a30..d88844e 100644
--- a/assets/js/paymentplan.min.js
+++ b/assets/js/paymentplan.min.js
@@ -1 +1 @@
-const planName=jQuery("#plan-name"),planAmount=jQuery("#plan-amount"),planInterval=jQuery("#plan-interval"),planDuration=jQuery("#plan-duration");jQuery(document).ready(function(){jQuery("#wpfooter").hide(),jQuery("#createPlan-btn").on("click",n=>{n.preventDefault(),jQuery.post("#",{amount:planAmount.val(),name:planName.val(),interval:planInterval.val(),duration:parseInt(planDuration.val())}).done(function(){location.reload()})})});
+let planName=jQuery("#plan-name"),planAmount=jQuery("#plan-amount"),planInterval=jQuery("#plan-interval"),planDuration=jQuery("#plan-duration");jQuery(document).ready(function(){jQuery("#wpfooter").hide(),jQuery("#createPlan-btn").on("click",n=>{n.preventDefault(),jQuery.post("#",{amount:planAmount.val(),name:planName.val(),interval:planInterval.val(),duration:parseInt(planDuration.val())}).done(function(){location.reload()})})});
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
deleted file mode 100755
index 0643775..0000000
--- a/bin/install-wp-tests.sh
+++ /dev/null
@@ -1,244 +0,0 @@
-#!/usr/bin/env bash
-
-if [ $# -lt 3 ] && [ -z $FLUTTERWAVE_PAYMENTS_DIR ]; then
- echo "usage: $0 [db-host] [wp-version] [skip-database-creation]"
- exit 1
-fi
-
-DB_NAME=${1-flutterwave_payments_tests}
-DB_USER=${2-root}
-DB_PASS=${3-$MYSQL_ROOT_PASSWORD}
-DB_HOST=${4-$WORDPRESS_DB_HOST}
-WP_VERSION=${5-latest}
-WC_VERSION=${6-latest}
-SKIP_DB_CREATE=${7-false}
-
-TMPDIR=${TMPDIR-/tmp}
-TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
-WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
-WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress}
-
-download() {
- if [ `which curl` ]; then
- curl -s "$1" > "$2";
- elif [ `which wget` ]; then
- wget -nv -O "$2" "$1"
- fi
-}
-
-wp() {
- WORKING_DIR="$PWD"
- cd "$WP_CORE_DIR"
-
- if [ ! -f $TMPDIR/wp-cli.phar ]; then
- download https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar "$TMPDIR/wp-cli.phar"
- fi
- php "$TMPDIR/wp-cli.phar" $@
-
- cd "$WORKING_DIR"
-}
-
-get_db_connection_flags() {
- # parse DB_HOST for port or socket references
- local DB_HOST_PARTS=(${DB_HOST//\:/ })
- local DB_HOSTNAME=${DB_HOST_PARTS[0]};
- local DB_SOCK_OR_PORT=${DB_HOST_PARTS[1]};
- local EXTRA_FLAGS=""
-
- if ! [ -z $DB_HOSTNAME ] ; then
- if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
- EXTRA_FLAGS=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
- elif ! [ -z $DB_SOCK_OR_PORT ] ; then
- EXTRA_FLAGS=" --socket=$DB_SOCK_OR_PORT"
- elif ! [ -z $DB_HOSTNAME ] ; then
- EXTRA_FLAGS=" --host=$DB_HOSTNAME --protocol=tcp"
- fi
- fi
- echo "--user=$DB_USER --password=$DB_PASS $EXTRA_FLAGS";
-}
-
-
-wait_db() {
- local MYSQLADMIN_FLAGS=$(get_db_connection_flags)
- local WAITS=0
-
- set +e
- mysqladmin status $MYSQLADMIN_FLAGS > /dev/null
- while [[ $? -ne 0 ]]; do
- ((WAITS++))
- if [ $WAITS -ge 6 ]; then
- echo "Maximum database wait time exceeded"
- exit 1
- fi;
- echo "Waiting until the database is available..."
- sleep 5s
- mysqladmin status $MYSQLADMIN_FLAGS > /dev/null
- done
- set -e
-}
-
-if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then
- WP_BRANCH=${WP_VERSION%\-*}
- WP_TESTS_TAG="branches/$WP_BRANCH"
-
-elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
- WP_TESTS_TAG="branches/$WP_VERSION"
-elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
- if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
- # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
- WP_TESTS_TAG="tags/${WP_VERSION%??}"
- else
- WP_TESTS_TAG="tags/$WP_VERSION"
- fi
-elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
- WP_TESTS_TAG="trunk"
-else
- # http serves a single offer, whereas https serves multiple. we only want one
- download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
- grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
- LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
- if [[ -z "$LATEST_VERSION" ]]; then
- echo "Latest WordPress version could not be found"
- exit 1
- fi
- WP_TESTS_TAG="tags/$LATEST_VERSION"
-fi
-set -ex
-
-install_wp() {
-
- if [ -d $WP_CORE_DIR ]; then
- return;
- fi
-
- mkdir -p $WP_CORE_DIR
-
- if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
- mkdir -p $TMPDIR/wordpress-trunk
- rm -rf $TMPDIR/wordpress-trunk/*
- svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress
- mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR
- else
- if [ $WP_VERSION == 'latest' ]; then
- local ARCHIVE_NAME='latest'
- elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
- # https serves multiple offers, whereas http serves single.
- download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json
- if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
- # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
- LATEST_VERSION=${WP_VERSION%??}
- else
- # otherwise, scan the releases and get the most up to date minor version of the major release
- local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'`
- LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1)
- fi
- if [[ -z "$LATEST_VERSION" ]]; then
- local ARCHIVE_NAME="wordpress-$WP_VERSION"
- else
- local ARCHIVE_NAME="wordpress-$LATEST_VERSION"
- fi
- else
- local ARCHIVE_NAME="wordpress-$WP_VERSION"
- fi
- download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz
- tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
- fi
-
- download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
-}
-
-configure_wp() {
- WP_SITE_URL="http://local.wordpress.test"
- wait_db
-
- if [[ ! -f "$WP_CORE_DIR/wp-config.php" ]]; then
- wp core config --dbname=$DB_NAME --dbuser=$DB_USER --dbpass=$DB_PASS --dbhost=$DB_HOST --dbprefix=wptests_
- fi
- wp core install --url="$WP_SITE_URL" --title="Example" --admin_user=admin --admin_password=password --admin_email=info@example.com --skip-email
-}
-
-install_test_suite() {
- # portable in-place argument for both GNU sed and Mac OSX sed
- if [[ $(uname -s) == 'Darwin' ]]; then
- local ioption='-i.bak'
- else
- local ioption='-i'
- fi
-
- # set up testing suite if it doesn't yet exist
- if [ ! -d $WP_TESTS_DIR ]; then
- # set up testing suite
- mkdir -p $WP_TESTS_DIR
- rm -rf $WP_TESTS_DIR/{includes,data}
- svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
- svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
- fi
-
- if [ ! -f wp-tests-config.php ]; then
- download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
- # remove all forward slashes in the end
- WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
- sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
- fi
-
-}
-
-recreate_db() {
- shopt -s nocasematch
- if [[ $1 =~ ^(y|yes)$ ]]
- then
- mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA
- create_db
- echo "Recreated the database ($DB_NAME)."
- else
- echo "Leaving the existing database ($DB_NAME) in place."
- fi
- shopt -u nocasematch
-}
-
-create_db() {
- mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
-}
-
-install_db() {
-
- if [ ${SKIP_DB_CREATE} = "true" ]; then
- return 0
- fi
-
- # parse DB_HOST for port or socket references
- local PARTS=(${DB_HOST//\:/ })
- local DB_HOSTNAME=${PARTS[0]};
- local DB_SOCK_OR_PORT=${PARTS[1]};
- local EXTRA=""
-
- if ! [ -z $DB_HOSTNAME ] ; then
- if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
- EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
- elif ! [ -z $DB_SOCK_OR_PORT ] ; then
- EXTRA=" --socket=$DB_SOCK_OR_PORT"
- elif ! [ -z $DB_HOSTNAME ] ; then
- EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
- fi
- fi
-
- # create database
- if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ]
- then
- echo "Reinstalling will delete the existing test database ($DB_NAME)"
- read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB
- recreate_db $DELETE_EXISTING_DB
- else
- create_db
- fi
-}
-
-install_wp
-install_db
-configure_wp
-install_test_suite
\ No newline at end of file
diff --git a/blocks/donation-form/block.json b/blocks/donation-form/block.json
new file mode 100644
index 0000000..084ac66
--- /dev/null
+++ b/blocks/donation-form/block.json
@@ -0,0 +1,73 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "flutterwave/donation-form",
+ "title": "Flutterwave Donation Form",
+ "category": "flutterwave",
+ "description": "Collect one-off or recurring donations, with suggested amounts.",
+ "keywords": [
+ "donate",
+ "donation",
+ "charity",
+ "give",
+ "recurring",
+ "flutterwave"
+ ],
+ "textdomain": "rave-payment-forms",
+ "attributes": {
+ "heading": {
+ "type": "string",
+ "default": ""
+ },
+ "message": {
+ "type": "string",
+ "default": ""
+ },
+ "currency": {
+ "type": "string",
+ "default": ""
+ },
+ "amounts": {
+ "type": "string",
+ "default": "1000, 5000, 10000"
+ },
+ "showFrequency": {
+ "type": "boolean",
+ "default": true
+ },
+ "accentColor": {
+ "type": "string",
+ "default": ""
+ },
+ "accentTextColor": {
+ "type": "string",
+ "default": ""
+ },
+ "borderRadius": {
+ "type": "number"
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": [
+ "left",
+ "center",
+ "right"
+ ],
+ "spacing": {
+ "margin": true
+ }
+ },
+ "example": {
+ "attributes": {
+ "heading": "Support our work",
+ "currency": "NGN"
+ }
+ },
+ "editorScript": "flw-blocks-editor",
+ "editorStyle": [
+ "flw_css",
+ "flw-blocks-editor-style"
+ ],
+ "render": "file:./render.php"
+}
diff --git a/blocks/donation-form/render.php b/blocks/donation-form/render.php
new file mode 100644
index 0000000..fbe6dd8
--- /dev/null
+++ b/blocks/donation-form/render.php
@@ -0,0 +1,29 @@
+ sanitize_text_field( (string) ( $attributes['heading'] ?? '' ) ),
+ 'message' => sanitize_textarea_field( (string) ( $attributes['message'] ?? '' ) ),
+ 'amounts' => sanitize_text_field( (string) ( $attributes['amounts'] ?? '' ) ),
+ 'show_frequency' => empty( $attributes['showFrequency'] ) ? '0' : '1',
+);
+
+if ( '' !== $currency ) {
+ $shortcode_atts['currency'] = $currency;
+}
+
+printf(
+ '%2$s
',
+ FLW_Blocks::wrapper( $attributes ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() escapes.
+ FLW_Shortcodes::donation_page_shortcode( $shortcode_atts, '' ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered and escaped by the form view.
+);
diff --git a/blocks/payment-button/block.json b/blocks/payment-button/block.json
new file mode 100644
index 0000000..bf1ecb8
--- /dev/null
+++ b/blocks/payment-button/block.json
@@ -0,0 +1,84 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "flutterwave/payment-button",
+ "title": "Flutterwave Payment Button",
+ "category": "flutterwave",
+ "description": "A one-line checkout for a fixed amount: an email field and a pay button.",
+ "keywords": [
+ "pay",
+ "button",
+ "checkout",
+ "buy",
+ "flutterwave"
+ ],
+ "textdomain": "rave-payment-forms",
+ "attributes": {
+ "amount": {
+ "type": "number",
+ "default": 5000
+ },
+ "currency": {
+ "type": "string",
+ "default": ""
+ },
+ "buttonText": {
+ "type": "string",
+ "default": ""
+ },
+ "collectName": {
+ "type": "boolean",
+ "default": false
+ },
+ "useUserEmail": {
+ "type": "boolean",
+ "default": true
+ },
+ "width": {
+ "type": "string",
+ "enum": [
+ "auto",
+ "full"
+ ],
+ "default": "auto"
+ },
+ "showSecured": {
+ "type": "boolean",
+ "default": true
+ },
+ "accentColor": {
+ "type": "string",
+ "default": ""
+ },
+ "accentTextColor": {
+ "type": "string",
+ "default": ""
+ },
+ "borderRadius": {
+ "type": "number"
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": [
+ "left",
+ "center",
+ "right"
+ ],
+ "spacing": {
+ "margin": true
+ }
+ },
+ "example": {
+ "attributes": {
+ "amount": 5000,
+ "currency": "NGN"
+ }
+ },
+ "editorScript": "flw-blocks-editor",
+ "editorStyle": [
+ "flw_css",
+ "flw-blocks-editor-style"
+ ],
+ "render": "file:./render.php"
+}
diff --git a/blocks/payment-button/render.php b/blocks/payment-button/render.php
new file mode 100644
index 0000000..12b3be6
--- /dev/null
+++ b/blocks/payment-button/render.php
@@ -0,0 +1,41 @@
+ $currency,
+ 'exclude' => implode( ',', $exclude ),
+ 'layout' => 'compact',
+ 'width' => 'full' === ( $attributes['width'] ?? '' ) ? 'full' : 'auto',
+ 'show_secured' => empty( $attributes['showSecured'] ) ? '0' : '1',
+ 'use_current_user_email' => empty( $attributes['useUserEmail'] ) ? 'no' : 'yes',
+);
+
+if ( $amount > 0 ) {
+ $shortcode_atts['amount'] = (string) $amount;
+}
+
+$label = trim( (string) ( $attributes['buttonText'] ?? '' ) );
+$label = '' !== $label ? $label : __( 'Pay {amount}', 'rave-payment-forms' );
+
+printf(
+ '%2$s
',
+ FLW_Blocks::wrapper( $attributes ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() escapes.
+ FLW_Shortcodes::pay_button_shortcode( $shortcode_atts, $label ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered and escaped by the form view.
+);
diff --git a/blocks/payment-form/block.json b/blocks/payment-form/block.json
new file mode 100644
index 0000000..cb03391
--- /dev/null
+++ b/blocks/payment-form/block.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "flutterwave/payment-form",
+ "title": "Flutterwave Payment Form",
+ "category": "flutterwave",
+ "description": "A full payment form with a fixed or customer-entered amount.",
+ "keywords": [
+ "pay",
+ "form",
+ "checkout",
+ "invoice",
+ "flutterwave"
+ ],
+ "textdomain": "rave-payment-forms",
+ "attributes": {
+ "heading": {
+ "type": "string",
+ "default": ""
+ },
+ "description": {
+ "type": "string",
+ "default": ""
+ },
+ "amount": {
+ "type": "number",
+ "default": 0
+ },
+ "currency": {
+ "type": "string",
+ "default": ""
+ },
+ "collectName": {
+ "type": "string",
+ "enum": [
+ "full",
+ "split",
+ "none"
+ ],
+ "default": "full"
+ },
+ "collectPhone": {
+ "type": "boolean",
+ "default": true
+ },
+ "buttonText": {
+ "type": "string",
+ "default": ""
+ },
+ "showSecured": {
+ "type": "boolean",
+ "default": true
+ },
+ "accentColor": {
+ "type": "string",
+ "default": ""
+ },
+ "accentTextColor": {
+ "type": "string",
+ "default": ""
+ },
+ "borderRadius": {
+ "type": "number"
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": [
+ "left",
+ "center",
+ "right"
+ ],
+ "spacing": {
+ "margin": true
+ }
+ },
+ "example": {
+ "attributes": {
+ "heading": "Pay an invoice",
+ "amount": 0
+ }
+ },
+ "editorScript": "flw-blocks-editor",
+ "editorStyle": [
+ "flw_css",
+ "flw-blocks-editor-style"
+ ],
+ "render": "file:./render.php"
+}
diff --git a/blocks/payment-form/render.php b/blocks/payment-form/render.php
new file mode 100644
index 0000000..ef65629
--- /dev/null
+++ b/blocks/payment-form/render.php
@@ -0,0 +1,48 @@
+ sanitize_text_field( (string) ( $attributes['heading'] ?? '' ) ),
+ 'description' => sanitize_textarea_field( (string) ( $attributes['description'] ?? '' ) ),
+ 'split_name' => 'split' === $name_fields ? '1' : '0',
+ 'show_secured' => empty( $attributes['showSecured'] ) ? '0' : '1',
+);
+
+if ( $exclude ) {
+ $shortcode_atts['exclude'] = implode( ',', $exclude );
+}
+
+if ( $amount > 0 ) {
+ $shortcode_atts['amount'] = (string) $amount;
+}
+
+if ( '' !== $currency ) {
+ $shortcode_atts['currency'] = $currency;
+}
+
+printf(
+ '%2$s
',
+ FLW_Blocks::wrapper( $attributes ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() escapes.
+ FLW_Shortcodes::pay_button_shortcode( $shortcode_atts, trim( (string) ( $attributes['buttonText'] ?? '' ) ) ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- rendered and escaped by the form view.
+);
diff --git a/blocks/payment-methods/block.json b/blocks/payment-methods/block.json
new file mode 100644
index 0000000..9805ac0
--- /dev/null
+++ b/blocks/payment-methods/block.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "flutterwave/payment-methods",
+ "title": "Flutterwave Payment Methods",
+ "category": "flutterwave",
+ "description": "Shows the payment methods your forms accept, taken from your Flutterwave settings.",
+ "keywords": [
+ "accepted",
+ "cards",
+ "badge",
+ "trust",
+ "secure",
+ "flutterwave"
+ ],
+ "textdomain": "rave-payment-forms",
+ "attributes": {
+ "label": {
+ "type": "string",
+ "default": "We accept"
+ },
+ "showSecured": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": [
+ "left",
+ "center",
+ "right"
+ ],
+ "spacing": {
+ "margin": true,
+ "padding": true
+ }
+ },
+ "example": {
+ "attributes": {}
+ },
+ "editorScript": "flw-blocks-editor",
+ "editorStyle": [
+ "flw_css",
+ "flw-blocks-editor-style"
+ ],
+ "render": "file:./render.php"
+}
diff --git a/blocks/payment-methods/render.php b/blocks/payment-methods/render.php
new file mode 100644
index 0000000..a627501
--- /dev/null
+++ b/blocks/payment-methods/render.php
@@ -0,0 +1,68 @@
+ ' ',
+ 'bank' => ' ',
+ 'phone' => ' ',
+ 'wallet' => ' ',
+ 'coin' => ' ',
+ 'code' => ' ',
+);
+
+$methods = array(
+ 'card' => array( __( 'Cards', 'rave-payment-forms' ), 'card' ),
+ 'banktransfer' => array( __( 'Bank transfer', 'rave-payment-forms' ), 'bank' ),
+ 'mobilemoney' => array( __( 'Mobile money', 'rave-payment-forms' ), 'phone' ),
+ 'applepay' => array( __( 'Apple Pay', 'rave-payment-forms' ), 'wallet' ),
+ 'googlepay' => array( __( 'Google Pay', 'rave-payment-forms' ), 'wallet' ),
+ 'opay' => array( __( 'OPay', 'rave-payment-forms' ), 'wallet' ),
+ 'stablecoin' => array( __( 'Stablecoin', 'rave-payment-forms' ), 'coin' ),
+ 'ussd' => array( __( 'USSD', 'rave-payment-forms' ), 'phone' ),
+ 'qr' => array( __( 'QR', 'rave-payment-forms' ), 'code' ),
+ 'nqr' => array( __( 'NQR', 'rave-payment-forms' ), 'code' ),
+ 'credit' => array( __( 'Credit', 'rave-payment-forms' ), 'card' ),
+ 'barter' => array( __( 'Barter', 'rave-payment-forms' ), 'wallet' ),
+);
+
+$options = FLW_Settings::payment_options();
+$enabled = array_merge( FLW_Settings::methods_from_option( $options ), FLW_Settings::advanced_from_option( $options ) );
+$label = trim( (string) ( $attributes['label'] ?? '' ) );
+?>
+>
+
+
+
+
+
+
+
+
+ Flutterwave'
+ );
+ ?>
+
+
+
+
diff --git a/blocks/pricing-card/block.json b/blocks/pricing-card/block.json
new file mode 100644
index 0000000..58e92e8
--- /dev/null
+++ b/blocks/pricing-card/block.json
@@ -0,0 +1,100 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "flutterwave/pricing-card",
+ "title": "Flutterwave Pricing Card",
+ "category": "flutterwave",
+ "description": "A plan or product card with a price, features and checkout.",
+ "keywords": [
+ "price",
+ "plan",
+ "product",
+ "pricing",
+ "tier",
+ "flutterwave"
+ ],
+ "textdomain": "rave-payment-forms",
+ "attributes": {
+ "planName": {
+ "type": "string",
+ "default": "Starter"
+ },
+ "amount": {
+ "type": "number",
+ "default": 25000
+ },
+ "currency": {
+ "type": "string",
+ "default": ""
+ },
+ "period": {
+ "type": "string",
+ "default": ""
+ },
+ "description": {
+ "type": "string",
+ "default": ""
+ },
+ "features": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "string"
+ }
+ },
+ "badge": {
+ "type": "string",
+ "default": ""
+ },
+ "highlighted": {
+ "type": "boolean",
+ "default": false
+ },
+ "buttonText": {
+ "type": "string",
+ "default": ""
+ },
+ "accentColor": {
+ "type": "string",
+ "default": ""
+ },
+ "accentTextColor": {
+ "type": "string",
+ "default": ""
+ },
+ "borderRadius": {
+ "type": "number"
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": [
+ "left",
+ "center",
+ "right"
+ ],
+ "spacing": {
+ "margin": true
+ }
+ },
+ "example": {
+ "attributes": {
+ "planName": "Pro",
+ "amount": 25000,
+ "currency": "NGN",
+ "period": "one-off",
+ "features": [
+ "Unlimited projects",
+ "Priority support"
+ ],
+ "badge": "Popular",
+ "highlighted": true
+ }
+ },
+ "editorScript": "flw-blocks-editor",
+ "editorStyle": [
+ "flw_css",
+ "flw-blocks-editor-style"
+ ],
+ "render": "file:./render.php"
+}
diff --git a/blocks/pricing-card/render.php b/blocks/pricing-card/render.php
new file mode 100644
index 0000000..a83af34
--- /dev/null
+++ b/blocks/pricing-card/render.php
@@ -0,0 +1,64 @@
+ $currency,
+ 'exclude' => 'fullname,phone',
+ 'layout' => 'compact',
+ 'width' => 'full',
+ 'show_secured' => '1',
+ 'use_current_user_email' => 'yes',
+);
+
+if ( $amount > 0 ) {
+ $checkout['amount'] = (string) $amount;
+}
+
+$classes = 'flw-pricing-card' . ( empty( $attributes['highlighted'] ) ? '' : ' is-highlighted' );
+$check = ' ';
+?>
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/admin/admin.scss b/client/admin/admin.scss
new file mode 100644
index 0000000..dfdef93
--- /dev/null
+++ b/client/admin/admin.scss
@@ -0,0 +1,2297 @@
+/**
+ * Flutterwave admin styles.
+ *
+ * Scoped under .flw-admin-root so nothing leaks into the rest of wp-admin, and
+ * the WordPress page wrapper padding is neutralised so the screen sits flush
+ * against the admin chrome the way the designs show it. The webfonts come from
+ * assets/css/flw-fonts.css, which the payment forms share.
+ */
+
+$flw-orange: #ff9b00;
+$flw-orange-soft: #fdc46b;
+$flw-orange-ink: #7a4a00;
+$flw-ink: #101828;
+$flw-body: #475467;
+$flw-muted: #98a2b3;
+$flw-border: #d0d5dd;
+$flw-line: #eaecf0;
+$flw-surface: #fff;
+$flw-radius: 8px;
+$flw-container: 1560px;
+$flw-gutter: 48px;
+$flw-serif: "Millik", "Iowan Old Style", "Palatino Linotype", palatino, georgia, serif;
+$flw-sans: "Moderat", "Inter", -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif;
+
+/* Neutralise the wp-admin content padding for our full-bleed page. */
+#wpcontent:has(.flw-admin-root) {
+ padding-left: 0;
+}
+
+.flw-admin-root {
+ background: $flw-surface;
+ color: $flw-ink;
+ font-family: $flw-sans;
+ font-size: 14px;
+ line-height: 1.5;
+ min-height: calc(100vh - 32px);
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ }
+
+ a {
+ color: $flw-orange;
+ font-weight: 500;
+ text-decoration: underline;
+
+ &:hover,
+ &:focus {
+ color: #d68300;
+ }
+ }
+
+ h1,
+ h2 {
+ font-family: $flw-serif;
+ font-weight: 400;
+ margin: 0;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Page shell */
+
+/* -------------------------------------------------------------------------- */
+
+/* The screen is centred in a container rather than hugging the admin sidebar,
+ and the two columns follow the ~1.4:1 form-to-stepper ratio from the designs
+ instead of two fixed widths. */
+.flw-page {
+
+ &__crumb {
+ border-bottom: 1px solid $flw-line;
+ }
+
+ &__crumb-inner {
+ align-items: center;
+ display: flex;
+ margin: 0 auto;
+ max-width: $flw-container;
+ min-height: 56px;
+ padding: 0 $flw-gutter;
+ width: 100%;
+ }
+
+ &__body {
+ display: block;
+ margin: 0 auto;
+ max-width: $flw-container;
+ padding: 40px $flw-gutter 80px;
+
+ /* The designs put the form, the gutter between the columns and the
+ stepper in a 774:184:500 ratio. Encoding that directly — including
+ the gap as a percentage of the same track — keeps the proportions
+ identical at any width instead of approximating them. */
+ &.has-aside {
+ column-gap: 12.6%;
+ display: grid;
+ grid-template-columns: minmax(0, 774fr) minmax(0, 500fr);
+ row-gap: 40px;
+ }
+ }
+
+ &__main {
+ min-width: 0;
+ }
+}
+
+.flw-crumb-btn {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: $flw-ink;
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 10px;
+ padding: 8px 0;
+
+ &--static {
+ cursor: default;
+ }
+
+ &:hover:not(&--static) {
+ color: $flw-orange;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Typography */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-heading {
+ font-size: 28px;
+ letter-spacing: -0.01em;
+ margin: 16px 0 4px;
+}
+
+.flw-subheading {
+ color: $flw-body;
+ margin: 0;
+}
+
+.flw-rule {
+ background: $flw-orange-soft;
+ border: 0;
+ height: 2px;
+ margin: 18px 0 28px;
+}
+
+.flw-step-badge {
+ background: #fcd9a0;
+ border-radius: 4px;
+ color: #4b2e00;
+ display: inline-block;
+ font-size: 12px;
+ padding: 5px 10px;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Stepper */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-stepper {
+ border: 1px solid $flw-line;
+ border-radius: 10px;
+ padding: 24px 28px;
+
+ &__item {
+ align-items: center;
+ color: $flw-muted;
+ display: flex;
+ gap: 14px;
+ position: relative;
+
+ &:not(:last-child) {
+ padding-bottom: 26px;
+
+ &::before {
+ background: $flw-line;
+ bottom: 4px;
+ content: "";
+ left: 11px;
+ position: absolute;
+ top: 24px;
+ width: 2px;
+ }
+ }
+
+ &.is-done {
+ color: $flw-ink;
+
+ .flw-stepper__dot {
+ background: #d1fadf;
+ border-color: #12b76a;
+ color: #039855;
+ }
+
+ &::before {
+ background: #12b76a;
+ }
+ }
+
+ &.is-active {
+ color: $flw-ink;
+
+ .flw-stepper__dot {
+ background: #e4e7ec;
+ border-color: #98a2b3;
+ color: $flw-ink;
+ }
+ }
+ }
+
+ &__dot {
+ align-items: center;
+ background: $flw-surface;
+ border: 2px solid $flw-border;
+ border-radius: 50%;
+ display: inline-flex;
+ flex: 0 0 auto;
+ height: 24px;
+ justify-content: center;
+ width: 24px;
+ }
+
+ &__label {
+ font-size: 15px;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Fields */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-field {
+ margin-bottom: 22px;
+
+ &__label {
+ align-items: center;
+ color: $flw-ink;
+ display: flex;
+ font-size: 14px;
+ gap: 6px;
+ margin-bottom: 6px;
+ }
+
+ &__hint,
+ &__error {
+ align-items: flex-start;
+ display: flex;
+ font-size: 13px;
+ gap: 6px;
+ margin: 8px 0 0;
+ }
+
+ &__hint {
+ color: $flw-muted;
+
+ svg {
+ flex: 0 0 auto;
+ margin-top: 2px;
+ }
+ }
+
+ &__error {
+ color: #d92d20;
+ }
+
+ &.has-error .flw-input {
+ border-color: #f04438;
+ }
+}
+
+.flw-input {
+ background: $flw-surface;
+ border: 1px solid $flw-border;
+ border-radius: $flw-radius;
+ color: $flw-ink;
+ display: block;
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 1.4;
+ padding: 12px 14px;
+ width: 100%;
+
+ &::placeholder {
+ color: $flw-muted;
+ }
+
+ /* wp-admin paints its own blue focus ring via `input[type="text"]:focus`,
+ which outranks a lone class. Restated at higher specificity below. */
+
+ &[readonly] {
+ background: #f9fafb;
+ color: $flw-body;
+ }
+
+ &--area {
+ min-height: 140px;
+ resize: vertical;
+ }
+}
+
+/* wp-admin's forms.css caps every select at 25rem (400px) and styles focus on
+ element selectors, both of which outrank the plain `.flw-input` class. These
+ need the extra specificity to land. */
+.flw-admin-root .flw-input {
+ background-color: $flw-surface;
+ border: 1px solid $flw-border;
+ border-radius: $flw-radius;
+ box-shadow: none;
+ color: $flw-ink;
+ font-size: 14px;
+ line-height: 1.4;
+ max-width: none;
+ min-height: 46px;
+ padding: 12px 14px;
+
+ &:focus {
+ border-color: $flw-orange;
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.18);
+ outline: none;
+ }
+}
+
+/* wp-admin gives selects its own padding and arrow image;
+ the chevron here is ours. */
+.flw-admin-root select.flw-input {
+ background-image: none;
+ height: auto;
+ line-height: 1.4;
+ padding-right: 42px;
+}
+
+.flw-admin-root .flw-input[readonly] {
+ background-color: #f9fafb;
+ color: $flw-body;
+}
+
+.flw-select {
+ position: relative;
+
+ select {
+ appearance: none;
+ cursor: pointer;
+ padding-right: 42px;
+ }
+
+ &__chevron {
+ align-items: center;
+ color: $flw-body;
+ display: flex;
+ height: 100%;
+ pointer-events: none;
+ position: absolute;
+ right: 14px;
+ top: 0;
+ }
+}
+
+.flw-copyfield {
+ display: flex;
+
+ .flw-input {
+ border-bottom-right-radius: 0;
+ border-right: 0;
+ border-top-right-radius: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__btn {
+ background: #808080;
+ border: 0;
+ border-radius: 0 $flw-radius $flw-radius 0;
+ color: #fff;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 14px;
+ min-width: 84px;
+ padding: 0 18px;
+
+ &:hover {
+ background: #6e6e6e;
+ }
+ }
+}
+
+/* Two short fields side by side, e.g. currency and country. */
+.flw-field-row {
+ column-gap: 20px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.flw-section-title {
+ border-top: 1px solid $flw-line;
+ font-size: 20px;
+ margin: 34px 0 4px;
+ padding-top: 26px;
+}
+
+.flw-section-note {
+ color: $flw-body;
+ margin: 0 0 18px;
+}
+
+/* Shows whether the entered keys will take real money. */
+.flw-mode {
+ border-radius: $flw-radius;
+ font-size: 13px;
+ margin: 0 0 22px;
+ padding: 10px 14px;
+
+ &--test {
+ background: #fff6e9;
+ color: $flw-orange-ink;
+ }
+
+ &--live {
+ background: #ecfdf3;
+ color: #05603a;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Checkboxes */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-checkbox {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ gap: 10px;
+ margin: 6px 0;
+
+ input {
+ height: 0;
+ opacity: 0;
+ position: absolute;
+ width: 0;
+ }
+
+ &__box {
+ align-items: center;
+ background: $flw-surface;
+ border: 1.5px solid $flw-border;
+ border-radius: 4px;
+ color: transparent;
+ display: inline-flex;
+ flex: 0 0 auto;
+ height: 18px;
+ justify-content: center;
+ width: 18px;
+ }
+
+ input:checked + &__box {
+ background: $flw-surface;
+ border-color: $flw-orange;
+ color: $flw-orange;
+ }
+
+ input:focus-visible + &__box {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.25);
+ }
+
+ &__label {
+ font-size: 14px;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Buttons */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-actions {
+ display: flex;
+ gap: 16px;
+ margin-top: 34px;
+
+ .flw-btn {
+ flex: 1 1 0;
+ }
+
+ &:not(.has-back) .flw-btn {
+ width: 100%;
+ }
+}
+
+.flw-btn {
+ align-items: center;
+ border: 0;
+ border-radius: $flw-radius;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: 15px;
+ font-weight: 500;
+ gap: 10px;
+ justify-content: center;
+ min-height: 48px;
+ padding: 0 28px;
+ transition: background-color 0.15s ease;
+
+ &--primary {
+ background: $flw-orange;
+ color: #1a1a1a;
+
+ &:hover:not(:disabled) {
+ background: #e08c00;
+ }
+
+ &:disabled {
+ background: $flw-orange-soft;
+ color: rgba(26, 26, 26, 0.7);
+ cursor: not-allowed;
+ }
+ }
+
+ &--secondary {
+ background: #808080;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #6e6e6e;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+ }
+
+ &__spinner {
+ animation: flw-spin 0.7s linear infinite;
+ border: 2px solid currentcolor;
+ border-radius: 50%;
+ border-right-color: transparent;
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.flw-link-btn {
+ background: none;
+ border: 0;
+ color: $flw-orange;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ margin-top: 18px;
+ text-decoration: underline;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Tooltip */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-tooltip {
+ color: $flw-muted;
+ cursor: help;
+ display: inline-flex;
+ position: relative;
+
+ &__bubble {
+ background: #2f2f2f;
+ border-radius: 8px;
+ bottom: calc(100% + 10px);
+ color: #fff;
+ font-size: 13px;
+ left: 0;
+ line-height: 1.45;
+ opacity: 0;
+ padding: 12px 14px;
+ pointer-events: none;
+ position: absolute;
+ transition: opacity 0.12s ease;
+ width: 240px;
+ z-index: 20;
+
+ &::after {
+ border: 6px solid transparent;
+ border-top-color: #2f2f2f;
+ content: "";
+ left: 12px;
+ position: absolute;
+ top: 100%;
+ }
+ }
+
+ &:hover &__bubble,
+ &:focus &__bubble,
+ &:focus-visible &__bubble {
+ opacity: 1;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Welcome */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-welcome {
+ margin: 0 auto;
+ max-width: 1120px;
+ padding: 56px 0 80px;
+ text-align: center;
+
+ .flw-wordmark {
+ display: block;
+ height: 40px;
+ margin: 0 auto;
+ width: auto;
+ }
+
+ &__title {
+ font-size: 46px;
+ line-height: 1.12;
+ margin: 28px 0 20px;
+ }
+
+ &__lede {
+ color: $flw-body;
+ font-size: 16px;
+ margin: 0 auto 34px;
+ max-width: 740px;
+ }
+}
+
+.flw-cards {
+ display: grid;
+ gap: 32px;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ margin-top: 76px;
+ text-align: left;
+}
+
+.flw-card {
+
+ h2 {
+ font-size: 20px;
+ margin: 20px 0 8px;
+ }
+
+ p {
+ color: $flw-body;
+ margin: 0 0 12px;
+ }
+
+ /* The exported artwork carries its own rounded tinted background, so the
+ wrapper only has to clip it. */
+ &__art {
+ border-radius: 10px;
+ display: flex;
+ overflow: hidden;
+ }
+}
+
+.flw-illustration {
+ display: block;
+ height: auto;
+ width: 100%;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Payment methods */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-methods {
+ border: 1px solid $flw-line;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.flw-method {
+ align-items: flex-start;
+ cursor: pointer;
+ display: grid;
+ gap: 16px;
+ grid-template-columns: 18px 56px minmax(0, 1fr);
+ padding: 20px 22px;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $flw-line;
+ }
+
+ &:hover {
+ background: #fcfcfd;
+ }
+
+ input {
+ height: 0;
+ opacity: 0;
+ position: absolute;
+ width: 0;
+ }
+
+ .flw-checkbox__box {
+ margin-top: 2px;
+ }
+
+ input:checked + .flw-checkbox__box {
+ border-color: $flw-orange;
+ color: $flw-orange;
+ }
+
+ input:focus-visible + .flw-checkbox__box {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.25);
+ }
+
+ &__mark {
+ align-items: center;
+ border: 1px solid $flw-line;
+ border-radius: 6px;
+ display: flex;
+ height: 36px;
+ justify-content: center;
+ width: 56px;
+ }
+
+ &__text {
+ display: block;
+ min-width: 0;
+ }
+
+ &__title {
+ display: block;
+ font-size: 15px;
+ font-weight: 500;
+ }
+
+ &__desc {
+ color: $flw-body;
+ display: block;
+ font-size: 13px;
+ margin-top: 2px;
+ }
+}
+
+.flw-method-mark {
+ height: 24px;
+ width: 40px;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Advanced disclosure */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-advanced {
+ border-top: 1px solid $flw-line;
+ margin-top: 26px;
+ padding-top: 18px;
+
+ &__toggle {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: $flw-body;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 8px;
+ padding: 4px 0;
+
+ &:hover {
+ color: $flw-ink;
+ }
+ }
+
+ &__caret {
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid currentcolor;
+ transition: transform 0.15s ease;
+
+ &.is-open {
+ transform: rotate(180deg);
+ }
+ }
+
+ &__body {
+ padding-top: 16px;
+ }
+
+ &__note {
+ color: $flw-muted;
+ font-size: 13px;
+ margin: 0 0 14px;
+ }
+
+ &__grid {
+ display: grid;
+ gap: 4px 24px;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ margin-bottom: 22px;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Tabs */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-tabs {
+ border-bottom: 1px solid $flw-line;
+ display: flex;
+ gap: 8px;
+ margin-bottom: 30px;
+}
+
+.flw-tab {
+ background: none;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ color: $flw-body;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ margin-bottom: -1px;
+ padding: 10px 14px;
+
+ &:hover {
+ color: $flw-ink;
+ }
+
+ &.is-active {
+ border-bottom-color: $flw-orange;
+ color: $flw-orange;
+ font-weight: 500;
+ }
+}
+
+.flw-tabpanel {
+ max-width: 720px;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Loading & success */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-loading {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ min-height: 60vh;
+
+ &__ring {
+ animation: flw-spin 0.9s linear infinite;
+ border: 3px solid #f2f4f7;
+ border-radius: 50%;
+ border-top-color: $flw-orange;
+ display: block;
+ height: 52px;
+ width: 52px;
+ }
+}
+
+.flw-success {
+ margin: 0 auto;
+ max-width: 480px;
+ padding: 18vh 20px 80px;
+ text-align: center;
+
+ h1 {
+ color: $flw-orange;
+ font-size: 24px;
+ margin: 24px 0 10px;
+ }
+
+ p {
+ color: $flw-body;
+ margin: 0;
+ }
+}
+
+.flw-success-art {
+ height: 96px;
+ width: 96px;
+}
+
+.flw-success__shortcode {
+ margin: 28px auto 0;
+ max-width: 420px;
+ text-align: left;
+
+ .flw-field__hint {
+ display: block;
+ }
+}
+
+.flw-success .flw-actions {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 420px;
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Toast */
+
+/* -------------------------------------------------------------------------- */
+
+.flw-toast {
+ align-items: center;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(16, 24, 40, 0.16);
+ display: flex;
+ font-size: 14px;
+ gap: 10px;
+ padding: 12px 14px;
+ position: fixed;
+ right: 24px;
+ top: 56px;
+ z-index: 9999;
+
+ &--success {
+ border-left: 4px solid #12b76a;
+ }
+
+ &--error {
+ border-left: 4px solid #f04438;
+ }
+
+ &__icon {
+ align-items: center;
+ background: #12b76a;
+ border-radius: 50%;
+ color: #fff;
+ display: inline-flex;
+ height: 20px;
+ justify-content: center;
+ width: 20px;
+ }
+
+ &__close {
+ background: none;
+ border: 0;
+ color: $flw-muted;
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+ padding: 0 2px;
+ }
+}
+
+@keyframes flw-spin {
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* List screens: Payment Forms and Transactions */
+
+/* -------------------------------------------------------------------------- */
+
+$flw-grey: #f2f4f7;
+$flw-shadow: 0 12px 32px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.06);
+
+.flw-btn--outline {
+ background: $flw-surface;
+ border: 1px solid $flw-border;
+ color: $flw-ink;
+ min-height: 40px;
+ padding: 0 18px;
+
+ &:hover:not(:disabled) {
+ background: $flw-grey;
+ }
+}
+
+.flw-list-header {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ justify-content: space-between;
+ margin-bottom: 20px;
+
+ .flw-btn {
+ min-height: 40px;
+ padding: 0 18px;
+ }
+}
+
+.flw-admin-root .flw-list-title {
+ font-size: 28px;
+ line-height: 1.2;
+}
+
+.flw-list-actions {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.flw-admin-root .flw-toolbar-btn {
+ align-items: center;
+ background: $flw-grey;
+ border: 0;
+ border-radius: $flw-radius;
+ color: $flw-ink;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 8px;
+ min-height: 40px;
+ padding: 0 16px;
+ text-decoration: none;
+
+ &:hover,
+ &[aria-expanded="true"] {
+ background: #e4e7ec;
+ color: $flw-ink;
+ }
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.flw-tab__count {
+ background: $flw-grey;
+ border-radius: 999px;
+ color: $flw-body;
+ font-size: 12px;
+ margin-left: 6px;
+ padding: 1px 8px;
+
+ .is-active & {
+ background: #fff3e0;
+ color: $flw-orange-ink;
+ }
+}
+
+/* Popovers */
+
+.flw-popover-anchor {
+ position: relative;
+}
+
+.flw-popover {
+ background: $flw-surface;
+ border: 1px solid $flw-line;
+ border-radius: 12px;
+ box-shadow: $flw-shadow;
+ max-width: calc(100vw - 32px);
+ padding: 20px;
+ position: absolute;
+ right: 0;
+ top: calc(100% + 8px);
+ width: 380px;
+ z-index: 20;
+
+ &__section {
+ border-top: 1px solid $flw-line;
+ margin-top: 16px;
+ padding-top: 16px;
+
+ h3 {
+ font-size: 13px;
+ font-weight: 500;
+ margin: 0 0 10px;
+ }
+ }
+
+ &__note {
+ align-items: center;
+ color: $flw-body;
+ display: flex;
+ font-size: 12px;
+ gap: 6px;
+ margin: 10px 0 0;
+
+ svg {
+ flex: none;
+ height: 14px;
+ width: 14px;
+ }
+ }
+
+ &__footer {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ margin-top: 20px;
+
+ .flw-btn {
+ min-height: 40px;
+ padding: 0 18px;
+ }
+ }
+}
+
+.flw-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.flw-preset {
+ background: $flw-surface;
+ border: 1px solid $flw-border;
+ border-radius: 999px;
+ color: $flw-body;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 13px;
+ padding: 6px 14px;
+
+ &:hover {
+ border-color: $flw-muted;
+ }
+
+ &.is-active {
+ background: #fff3e0;
+ border-color: $flw-orange;
+ color: $flw-orange-ink;
+ font-weight: 500;
+ }
+}
+
+.flw-daterange {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: 1fr 1fr;
+}
+
+.flw-admin-root .flw-daterange__input,
+.flw-admin-root .flw-multiselect__control {
+ background: $flw-surface;
+ border: 1px solid $flw-border;
+ border-radius: $flw-radius;
+ color: $flw-ink;
+ font-family: inherit;
+ font-size: 14px;
+ min-height: 40px;
+ padding: 0 12px;
+ width: 100%;
+
+ &:focus {
+ border-color: $flw-orange;
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.18);
+ outline: none;
+ }
+}
+
+.flw-filter-field {
+ display: block;
+
+ &__label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 8px;
+ }
+}
+
+.flw-multiselect {
+ position: relative;
+
+ .flw-multiselect__control {
+ cursor: pointer;
+ overflow: hidden;
+ padding-right: 36px;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .is-placeholder,
+ input::placeholder {
+ color: $flw-muted;
+ }
+
+ &__chevron {
+ color: $flw-muted;
+ pointer-events: none;
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+
+ .is-open & {
+ transform: translateY(-50%) rotate(180deg);
+ }
+ }
+
+ &__list {
+ background: $flw-surface;
+ border: 1px solid $flw-line;
+ border-radius: $flw-radius;
+ box-shadow: $flw-shadow;
+ left: 0;
+ list-style: none;
+ margin: 4px 0 0;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 4px;
+ position: absolute;
+ right: 0;
+ z-index: 2;
+
+ li {
+ margin: 0;
+ }
+ }
+
+ &__option {
+ align-items: center;
+ border-radius: 6px;
+ cursor: pointer;
+ display: flex;
+ gap: 10px;
+ padding: 8px 10px;
+
+ &:hover {
+ background: $flw-grey;
+ }
+
+ input {
+ height: 1px;
+ opacity: 0;
+ position: absolute;
+ width: 1px;
+ }
+ }
+
+ &__box {
+ align-items: center;
+ border: 1px solid $flw-border;
+ border-radius: 4px;
+ color: transparent;
+ display: inline-flex;
+ flex: none;
+ height: 16px;
+ justify-content: center;
+ width: 16px;
+
+ svg {
+ height: 10px;
+ width: 10px;
+ }
+
+ input:checked + & {
+ background: $flw-orange;
+ border-color: $flw-orange;
+ color: #fff;
+ }
+
+ input:focus-visible + & {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.3);
+ }
+ }
+
+ &__empty {
+ color: $flw-muted;
+ padding: 8px 10px;
+ }
+}
+
+/* Tables */
+
+.flw-table-wrap {
+ border: 1px solid $flw-line;
+ border-radius: 12px;
+ overflow-x: auto;
+
+ &.is-loading {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+}
+
+.flw-skeleton {
+ animation: flw-pulse 1.2s ease-in-out infinite;
+ background: linear-gradient(to bottom, $flw-grey 0 44px, transparent 44px) 0 0 / 100% 60px repeat-y;
+ height: 300px;
+}
+
+@keyframes flw-pulse {
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.flw-table {
+ border-collapse: collapse;
+ min-width: 640px;
+ width: 100%;
+
+ th {
+ background: $flw-grey;
+ color: $flw-body;
+ font-size: 13px;
+ font-weight: 500;
+ padding: 12px 20px;
+ text-align: left;
+ white-space: nowrap;
+ }
+
+ td {
+ border-top: 1px solid $flw-line;
+ padding: 16px 20px;
+ vertical-align: middle;
+ }
+
+ tbody tr.is-clickable {
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ background: #fcfcfd;
+ outline: none;
+ }
+ }
+
+ &__actions {
+ text-align: right;
+ white-space: nowrap;
+ }
+}
+
+.flw-cell-primary {
+ color: $flw-ink;
+ display: block;
+ font-weight: 500;
+}
+
+.flw-cell-secondary {
+ color: $flw-muted;
+ display: block;
+ font-size: 13px;
+}
+
+.flw-cell-muted {
+ color: $flw-muted;
+}
+
+.flw-admin-root .flw-row-link {
+ align-items: center;
+ color: $flw-body;
+ display: inline-flex;
+ gap: 4px;
+ margin-left: 16px;
+ text-decoration: none;
+
+ &:hover {
+ color: $flw-orange;
+ }
+
+ svg {
+ height: 12px;
+ width: 12px;
+ }
+}
+
+.flw-form-cell {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+
+ &__icon {
+ align-items: center;
+ background: #fff3e0;
+ border-radius: $flw-radius;
+ color: $flw-orange-ink;
+ display: inline-flex;
+ flex: none;
+ height: 36px;
+ justify-content: center;
+ width: 36px;
+
+ svg {
+ fill: currentcolor;
+ height: 20px;
+ width: 20px;
+ }
+ }
+}
+
+.flw-pill {
+ background: $flw-grey;
+ border-radius: 999px;
+ color: $flw-body;
+ display: inline-block;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 3px 10px;
+ white-space: nowrap;
+
+ &--successful,
+ &--publish {
+ background: #ecfdf3;
+ color: #027a48;
+ }
+
+ &--pending,
+ &--future {
+ background: #fffaeb;
+ color: #b54708;
+ }
+
+ &--failed {
+ background: #fef3f2;
+ color: #b42318;
+ }
+
+ &--review {
+ background: #f4f3ff;
+ color: #5925dc;
+ }
+
+ &--synced {
+ background: #eff8ff;
+ color: #175cd3;
+ }
+}
+
+.flw-pagination {
+ align-items: center;
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+
+ &__status {
+ color: $flw-muted;
+ font-size: 13px;
+ margin-left: auto;
+ }
+}
+
+.flw-page-btn {
+ background: $flw-surface;
+ border: 1px solid $flw-border;
+ border-radius: $flw-radius;
+ color: $flw-ink;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ min-height: 36px;
+ padding: 0 14px;
+
+ &:disabled {
+ color: $flw-muted;
+ cursor: not-allowed;
+ }
+
+ &:hover:not(:disabled) {
+ background: $flw-grey;
+ }
+}
+
+.flw-empty {
+ color: $flw-body;
+ padding: 48px 24px;
+ text-align: center;
+
+ p {
+ margin: 0 auto 16px;
+ max-width: 460px;
+ }
+
+ &--hero {
+ border: 1px dashed $flw-border;
+ border-radius: 12px;
+ padding: 56px 24px;
+
+ h2 {
+ font-size: 26px;
+ margin-bottom: 8px;
+ }
+ }
+
+ &__art svg {
+ height: auto;
+ margin-bottom: 16px;
+ max-width: 160px;
+ }
+}
+
+.flw-list-footnote {
+ color: $flw-muted;
+ font-size: 12px;
+ margin: 16px 0 0;
+}
+
+/* Drawer & modal */
+
+.flw-overlay {
+ background: rgba(16, 24, 40, 0.4);
+ display: flex;
+ inset: 0;
+ justify-content: flex-end;
+ position: fixed;
+ z-index: 100000;
+
+ &--center {
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ }
+}
+
+.flw-drawer,
+.flw-modal {
+ background: $flw-surface;
+ display: flex;
+ flex-direction: column;
+
+ &__header {
+ align-items: center;
+ border-bottom: 1px solid $flw-line;
+ display: flex;
+ justify-content: space-between;
+ padding: 20px 24px;
+
+ h2 {
+ font-size: 22px;
+ }
+ }
+
+ &__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+ }
+
+ &__footer {
+ align-items: center;
+ border-top: 1px solid $flw-line;
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ padding: 16px 24px;
+
+ .flw-btn {
+ min-height: 40px;
+ padding: 0 18px;
+ }
+ }
+}
+
+.flw-drawer {
+ height: 100%;
+ max-width: 100%;
+ width: 440px;
+
+ &__amount {
+ font-family: $flw-serif;
+ font-size: 32px;
+ margin: 0 0 20px;
+ }
+
+ &__body > .flw-pill {
+ margin-bottom: 12px;
+ }
+
+ &__footer {
+ justify-content: space-between;
+ }
+}
+
+.flw-modal {
+ border-radius: 16px;
+ box-shadow: $flw-shadow;
+ max-height: calc(100vh - 64px);
+ max-width: 100%;
+ width: 880px;
+}
+
+.flw-icon-btn {
+ background: none;
+ border: 0;
+ border-radius: 6px;
+ color: $flw-body;
+ cursor: pointer;
+ font-size: 24px;
+ height: 32px;
+ line-height: 1;
+ width: 32px;
+
+ &:hover {
+ background: $flw-grey;
+ }
+}
+
+.flw-details {
+ margin: 0;
+}
+
+.flw-detail {
+ border-bottom: 1px solid $flw-line;
+ display: grid;
+ gap: 12px;
+ grid-template-columns: 130px minmax(0, 1fr);
+ padding: 12px 0;
+
+ dt {
+ color: $flw-muted;
+ }
+
+ dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ }
+}
+
+.flw-admin-root .flw-link-danger {
+ background: none;
+ border: 0;
+ color: #b42318;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0;
+ text-decoration: underline;
+
+ &:disabled {
+ opacity: 0.6;
+ }
+}
+
+/* Form builder */
+
+.flw-builder__intro {
+ color: $flw-body;
+ margin: 0 0 16px;
+}
+
+.flw-type-grid {
+ display: grid;
+ gap: 14px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+}
+
+.flw-type-card {
+ background: $flw-surface;
+ border: 1px solid $flw-line;
+ border-radius: 12px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ font-family: inherit;
+ gap: 6px;
+ padding: 20px;
+ text-align: left;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+ &:hover,
+ &:focus-visible {
+ border-color: $flw-orange;
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.15);
+ outline: none;
+ }
+
+ &__icon {
+ align-items: center;
+ background: #fff3e0;
+ border-radius: $flw-radius;
+ color: $flw-orange-ink;
+ display: inline-flex;
+ height: 40px;
+ justify-content: center;
+ margin-bottom: 8px;
+ width: 40px;
+
+ svg {
+ fill: currentcolor;
+ height: 22px;
+ width: 22px;
+ }
+ }
+
+ &__title {
+ color: $flw-ink;
+ font-size: 15px;
+ font-weight: 500;
+ }
+
+ &__desc {
+ color: $flw-body;
+ font-size: 13px;
+ }
+}
+
+.flw-builder {
+ display: grid;
+ gap: 28px;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+
+ &__fields .flw-field-row {
+ gap: 14px;
+ }
+
+ &__preview {
+ align-self: start;
+ background: #f9fafb;
+ border: 1px solid $flw-line;
+ border-radius: 12px;
+ min-height: 260px;
+ padding: 20px;
+ position: sticky;
+ top: 0;
+ }
+
+ &__preview-label {
+ color: $flw-muted;
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ margin-bottom: 14px;
+ text-transform: uppercase;
+ }
+
+ &__preview-frame {
+ pointer-events: none;
+ }
+
+ &__preview-note {
+ color: $flw-muted;
+ margin: 0;
+ }
+}
+
+@media screen and (max-width: 782px) {
+
+ .flw-builder {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .flw-builder__preview {
+ position: static;
+ }
+
+ .flw-popover {
+ left: 0;
+ right: auto;
+ }
+}
+
+
+/* Integrations */
+
+.flw-integrations__lead {
+ color: $flw-body;
+ margin: 8px 0 0;
+ max-width: 640px;
+}
+
+.flw-integrations__header {
+ align-items: flex-end;
+}
+
+.flw-integrations__count {
+ background: #ecfdf3;
+ border-radius: 999px;
+ color: #027a48;
+ font-size: 13px;
+ font-weight: 500;
+ padding: 6px 14px;
+}
+
+.flw-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 28px;
+}
+
+.flw-admin-root .flw-section-title {
+ color: $flw-body;
+ font-family: $flw-sans;
+ font-size: 13px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ margin: 0 0 14px;
+ text-transform: uppercase;
+}
+
+.flw-integrations__grid {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ margin-bottom: 36px;
+
+ &.is-loading {
+ animation: flw-pulse 1.2s ease-in-out infinite;
+ background: $flw-grey;
+ border-radius: 12px;
+ height: 260px;
+ }
+}
+
+.flw-integration {
+ background: $flw-surface;
+ border: 1px solid $flw-line;
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+ padding: 22px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+ &:not(.is-soon):hover {
+ border-color: $flw-border;
+ box-shadow: 0 4px 16px rgba(16, 24, 40, 0.06);
+ }
+
+ &__head {
+ align-items: flex-start;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ }
+
+ &__logo {
+ align-items: center;
+ border-radius: 10px;
+ display: inline-flex;
+ font-size: 15px;
+ font-weight: 700;
+ height: 44px;
+ justify-content: center;
+ letter-spacing: 0.02em;
+ width: 44px;
+ }
+
+ &__status {
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 3px 10px;
+
+ &.is-active {
+ background: #ecfdf3;
+ color: #027a48;
+ }
+
+ &.is-ready {
+ background: #fff3e0;
+ color: $flw-orange-ink;
+ }
+
+ &.is-idle,
+ &.is-soon {
+ background: $flw-grey;
+ color: $flw-body;
+ }
+ }
+
+ &__name {
+ color: $flw-ink;
+ font-size: 17px;
+ font-weight: 500;
+ margin: 0;
+ }
+
+ &__category {
+ color: $flw-muted;
+ font-size: 13px;
+ }
+
+ &__desc {
+ color: $flw-body;
+ flex: 1;
+ margin: 12px 0 0;
+ }
+
+ &__hint {
+ color: $flw-muted;
+ font-size: 12px;
+ margin: 10px 0 0;
+ }
+
+ &__warning {
+ background: #fffaeb;
+ border-radius: $flw-radius;
+ color: #b54708;
+ font-size: 13px;
+ margin: 12px 0 0;
+ padding: 8px 12px;
+ }
+
+ &__foot {
+ align-items: center;
+ border-top: 1px solid $flw-line;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+ margin-top: 18px;
+ padding-top: 16px;
+
+ .flw-btn {
+ min-height: 38px;
+ padding: 0 16px;
+ }
+ }
+
+ &__blocked {
+ color: $flw-muted;
+ font-size: 13px;
+ }
+
+ &.is-soon {
+ background: #fcfcfd;
+
+ .flw-integration__logo {
+ filter: grayscale(1);
+ opacity: 0.6;
+ }
+
+ .flw-integration__name,
+ .flw-integration__desc {
+ color: $flw-muted;
+ }
+ }
+}
+
+.flw-admin-root .flw-integration__manage {
+ align-items: center;
+ color: $flw-ink;
+ display: inline-flex;
+ gap: 6px;
+ text-decoration: none;
+
+ &:hover {
+ color: $flw-orange;
+ }
+
+ svg {
+ height: 12px;
+ width: 12px;
+ }
+}
+
+.flw-switch {
+ background: $flw-border;
+ border: 0;
+ border-radius: 999px;
+ cursor: pointer;
+ flex: none;
+ height: 24px;
+ padding: 0;
+ position: relative;
+ transition: background-color 0.15s ease;
+ width: 44px;
+
+ &__thumb {
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(16, 24, 40, 0.2);
+ height: 20px;
+ left: 2px;
+ position: absolute;
+ top: 2px;
+ transition: transform 0.15s ease;
+ width: 20px;
+ }
+
+ &.is-on {
+ background: $flw-orange;
+
+ .flw-switch__thumb {
+ transform: translateX(20px);
+ }
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.3);
+ outline: none;
+ }
+
+ &:disabled {
+ cursor: wait;
+ opacity: 0.6;
+ }
+}
+
+
+/* Help menu and guided tips */
+
+.flw-page__actions {
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+
+.flw-help-btn {
+ align-items: center;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ color: $flw-body;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 6px;
+ padding: 6px 12px 6px 8px;
+
+ &:hover,
+ &[aria-expanded="true"] {
+ background: $flw-grey;
+ color: $flw-ink;
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.3);
+ outline: none;
+ }
+}
+
+.flw-help {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: -8px;
+ width: 300px;
+
+ &__item,
+ &__row {
+ align-items: center;
+ border-radius: $flw-radius;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ padding: 10px 12px;
+ text-align: left;
+ }
+
+ &__item {
+ background: none;
+ border: 0;
+ cursor: pointer;
+ flex-direction: column;
+ align-items: flex-start;
+ font-family: inherit;
+ gap: 2px;
+
+ &:hover,
+ &:focus-visible {
+ background: $flw-grey;
+ outline: none;
+ }
+ }
+
+ &__label {
+ color: $flw-ink;
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ &__hint {
+ color: $flw-muted;
+ display: block;
+ font-size: 13px;
+ }
+}
+
+.flw-admin-root .flw-help__link {
+ align-items: center;
+ border-top: 1px solid $flw-line;
+ color: $flw-body;
+ display: flex;
+ font-size: 13px;
+ gap: 6px;
+ margin-top: 4px;
+ padding: 12px 12px 4px;
+ text-decoration: none;
+
+ &:hover {
+ color: $flw-orange;
+ }
+
+ svg {
+ height: 12px;
+ width: 12px;
+ }
+}
+
+.flw-tour {
+
+ &__blocker,
+ &__dim {
+ inset: 0;
+ position: fixed;
+ z-index: 100000;
+ }
+
+ &__dim {
+ background: rgba(16, 24, 40, 0.5);
+ }
+
+ &__spot {
+ border-radius: 10px;
+ box-shadow: 0 0 0 9999px rgba(16, 24, 40, 0.5);
+ pointer-events: none;
+ position: fixed;
+ transition: top 0.25s ease, left 0.25s ease, width 0.25s ease, height 0.25s ease;
+ z-index: 100000;
+ }
+
+ &__card {
+ background: $flw-surface;
+ border-radius: 12px;
+ box-shadow: 0 20px 48px rgba(16, 24, 40, 0.24);
+ color: $flw-ink;
+ max-width: calc(100vw - 32px);
+ padding: 20px 20px 16px;
+ position: fixed;
+ width: 340px;
+ z-index: 100001;
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ &__arrow {
+ background: $flw-surface;
+ height: 12px;
+ position: absolute;
+ transform: translateX(-50%) rotate(45deg);
+ width: 12px;
+
+ .is-bottom & {
+ top: -6px;
+ }
+
+ .is-top & {
+ bottom: -6px;
+ }
+
+ .is-right & {
+ left: 0;
+ transform: translateY(-50%) rotate(45deg);
+ }
+ }
+
+ &__count {
+ color: $flw-orange-ink;
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ margin-bottom: 6px;
+ }
+
+ .flw-admin-root &__title,
+ &__title {
+ font-family: $flw-sans;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.35;
+ margin: 0 0 6px;
+ }
+
+ &__body {
+ color: $flw-body;
+ font-size: 14px;
+ line-height: 1.5;
+ margin: 0;
+ }
+
+ &__foot {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-top: 18px;
+ }
+
+ &__nav {
+ display: flex;
+ gap: 8px;
+
+ .flw-btn {
+ font-size: 14px;
+ min-height: 36px;
+ padding: 0 16px;
+ }
+ }
+
+ &__skip {
+ background: none;
+ border: 0;
+ color: $flw-muted;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 13px;
+ padding: 4px 0;
+
+ &:hover,
+ &:focus-visible {
+ color: $flw-ink;
+ text-decoration: underline;
+ }
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+
+ .flw-tour__spot {
+ transition: none;
+ }
+}
+
+@media screen and (max-width: 782px) {
+
+ .flw-help-btn span {
+ display: none;
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+
+/* Responsive */
+
+/* -------------------------------------------------------------------------- */
+
+@media screen and (max-width: 1100px) {
+
+ .flw-page__body.has-aside {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .flw-page__aside {
+ max-width: 720px;
+ order: -1;
+ }
+
+}
+
+@media screen and (max-width: 782px) {
+
+ .flw-field-row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .flw-page__crumb-inner,
+ .flw-page__body {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .flw-welcome__title {
+ font-size: 32px;
+ }
+
+ .flw-method {
+ grid-template-columns: 18px minmax(0, 1fr);
+
+ &__mark {
+ display: none;
+ }
+ }
+}
diff --git a/client/admin/app.js b/client/admin/app.js
new file mode 100644
index 0000000..9820c24
--- /dev/null
+++ b/client/admin/app.js
@@ -0,0 +1,131 @@
+/**
+ * Root of the Flutterwave admin app.
+ *
+ * Owns the settings state and decides which surface to show: the welcome
+ * screen for a fresh install, the wizard once activation starts, and the
+ * tabbed settings screen for anyone already configured.
+ */
+
+import { useState, useEffect, useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { LoadingScreen, Toast } from './components';
+import Welcome from './screens/Welcome';
+import Wizard from './screens/Wizard';
+import Success from './screens/Success';
+import Settings from './screens/Settings';
+import { fetchSettings } from './lib/api';
+
+const VIEW = {
+ LOADING: 'loading',
+ WELCOME: 'welcome',
+ WIZARD: 'wizard',
+ SUCCESS: 'success',
+ SETTINGS: 'settings',
+};
+
+const App = () => {
+ const [ view, setView ] = useState( VIEW.LOADING );
+ const [ values, setValues ] = useState( null );
+ const [ notice, setNotice ] = useState( null );
+
+ useEffect( () => {
+ let cancelled = false;
+
+ fetchSettings()
+ .then( ( payload ) => {
+ if ( cancelled ) {
+ return;
+ }
+ setValues( payload );
+ setView(
+ payload.onboardingComplete ? VIEW.SETTINGS : VIEW.WELCOME
+ );
+ } )
+ .catch( ( error ) => {
+ if ( cancelled ) {
+ return;
+ }
+ setNotice( {
+ status: 'error',
+ message:
+ error?.message ||
+ __(
+ 'Could not load your Flutterwave settings.',
+ 'rave-payment-forms'
+ ),
+ } );
+ setView( VIEW.WELCOME );
+ setValues( ( current ) => current || {} );
+ } );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [] );
+
+ const setValue = useCallback( ( key, value ) => {
+ setValues( ( current ) => ( { ...current, [ key ]: value } ) );
+ }, [] );
+
+ // The API echoes the persisted state back, so trust it over local edits.
+ const onSaved = useCallback( ( payload ) => setValues( payload ), [] );
+
+ const onNotice = useCallback(
+ ( status, message ) => setNotice( { status, message } ),
+ []
+ );
+
+ useEffect( () => {
+ if ( ! notice ) {
+ return undefined;
+ }
+
+ const timer = setTimeout( () => setNotice( null ), 4000 );
+ return () => clearTimeout( timer );
+ }, [ notice ] );
+
+ if ( VIEW.LOADING === view || ! values ) {
+ return ;
+ }
+
+ return (
+ <>
+ { notice && (
+ setNotice( null ) }
+ />
+ ) }
+
+ { VIEW.WELCOME === view && (
+ setView( VIEW.WIZARD ) } />
+ ) }
+
+ { VIEW.WIZARD === view && (
+ setView( VIEW.SUCCESS ) }
+ onError={ ( message ) => onNotice( 'error', message ) }
+ />
+ ) }
+
+ { VIEW.SUCCESS === view && (
+ setView( VIEW.SETTINGS ) } />
+ ) }
+
+ { VIEW.SETTINGS === view && (
+
+ ) }
+ >
+ );
+};
+
+export default App;
diff --git a/client/admin/components/index.js b/client/admin/components/index.js
new file mode 100644
index 0000000..a5f070a
--- /dev/null
+++ b/client/admin/components/index.js
@@ -0,0 +1,403 @@
+/**
+ * Shared presentational components for the Flutterwave admin app.
+ */
+
+import { useState, useRef, useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { ChevronLeft, ChevronDown, InfoIcon, CheckIcon } from '../icons';
+
+/**
+ * Page chrome: the sticky breadcrumb bar plus the two-column body.
+ *
+ * @param {Object} props
+ * @param {string} props.backLabel Label shown next to the chevron.
+ * @param {Function} [props.onBack] Click handler; omit for a static crumb.
+ * @param {*} props.children Page body.
+ * @param {*} [props.aside] Right-hand rail (the stepper).
+ * @param {boolean} [props.wide] Let the main column fill the screen, for
+ * surfaces that centre or span their own
+ * content.
+ * @param {*} [props.actions] Controls at the right of the breadcrumb bar.
+ * @return {Element} The page shell.
+ */
+export const Page = ( {
+ backLabel,
+ onBack,
+ children,
+ aside,
+ wide,
+ actions,
+} ) => (
+
+
+
+ { onBack ? (
+
+
+ { backLabel }
+
+ ) : (
+
+
+ { backLabel }
+
+ ) }
+ { actions && (
+
{ actions }
+ ) }
+
+
+
+
+ { children }
+
+ { aside &&
{ aside }
}
+
+
+);
+
+/**
+ * "Step 1 of 3" pill.
+ *
+ * @param {Object} props
+ * @param {number} props.current One-based step index.
+ * @param {number} props.total Total steps.
+ * @return {Element} The pill.
+ */
+export const StepBadge = ( { current, total } ) => (
+
+ {
+ /* translators: 1: current step number, 2: total number of steps. */
+ __( 'Step', 'rave-payment-forms' )
+ }{ ' ' }
+
+ { current } { __( 'of', 'rave-payment-forms' ) } { total }
+
+
+);
+
+/**
+ * The progress rail on the right of each wizard step.
+ *
+ * @param {Object} props
+ * @param {Array} props.steps Step descriptors.
+ * @param {number} props.activeIndex Zero-based index of the current step.
+ * @return {Element} The stepper card.
+ */
+export const Stepper = ( { steps, activeIndex } ) => (
+
+ { steps.map( ( step, index ) => {
+ const done = index < activeIndex;
+ const active = index === activeIndex;
+
+ return (
+
+
+ { ( done || active ) && }
+
+ { step.label }
+
+ );
+ } ) }
+
+);
+
+/**
+ * Small "i" affordance that reveals help text on hover and focus.
+ *
+ * @param {Object} props
+ * @param {string} props.text Tooltip copy.
+ * @return {Element} The tooltip trigger.
+ */
+export const Tooltip = ( { text } ) => (
+
+
+ { text }
+
+);
+
+/**
+ * Label + control wrapper with optional tooltip and error text.
+ *
+ * @param {Object} props
+ * @param {string} props.id Control id.
+ * @param {string} props.label Field label.
+ * @param {string} [props.tooltip] Help text.
+ * @param {string} [props.error] Validation message.
+ * @param {string} [props.hint] Persistent helper text below the control.
+ * @param {*} props.children The control itself.
+ * @return {Element} The field.
+ */
+export const Field = ( { id, label, tooltip, error, hint, children } ) => (
+
+
+ { label }
+ { tooltip && }
+
+ { children }
+ { hint && (
+
+
+ { hint }
+
+ ) }
+ { error &&
{ error }
}
+
+);
+
+/**
+ * Text input.
+ *
+ * @param {Object} props Standard input props.
+ * @return {Element} The input.
+ */
+export const TextInput = ( props ) => (
+
+);
+
+/**
+ * Multi-line input.
+ *
+ * @param {Object} props Standard textarea props.
+ * @return {Element} The textarea.
+ */
+export const TextArea = ( props ) => (
+
+);
+
+/**
+ * Select with a custom chevron.
+ *
+ * @param {Object} props
+ * @param {Array} props.options `{ value, label }` pairs.
+ * @param {string} [props.placeholder] Shown when the value is empty.
+ * @return {Element} The select.
+ */
+export const Select = ( { options, placeholder, ...rest } ) => (
+
+
+ { placeholder && { placeholder } }
+ { options.map( ( option ) => (
+
+ { option.label }
+
+ ) ) }
+
+
+
+
+
+);
+
+/**
+ * Square checkbox with an inline label.
+ *
+ * @param {Object} props
+ * @param {string} props.id Control id.
+ * @param {boolean} props.checked Checked state.
+ * @param {Function} props.onChange Receives the next boolean.
+ * @param {string} props.label Inline label.
+ * @return {Element} The checkbox row.
+ */
+export const Checkbox = ( { id, checked, onChange, label } ) => (
+
+ onChange( event.target.checked ) }
+ />
+
+
+
+ { label }
+
+);
+
+/**
+ * Primary/secondary button. Disabled primaries keep the faded orange from the
+ * designs rather than going grey, so the affordance stays readable.
+ *
+ * @param {Object} props
+ * @param {string} [props.variant] `primary` or `secondary`.
+ * @param {boolean} [props.busy] Renders the spinner and blocks clicks.
+ * @param {*} props.children Button label.
+ * @return {Element} The button.
+ */
+export const Button = ( { variant = 'primary', busy, children, ...rest } ) => (
+
+ { busy && }
+ { children }
+
+);
+
+/**
+ * Read-only field with a Copy button, used for the webhook URL.
+ *
+ * @param {Object} props
+ * @param {string} props.value URL to copy.
+ * @param {string} props.id Control id.
+ * @return {Element} The copy field.
+ */
+export const CopyField = ( { value, id } ) => {
+ const [ copied, setCopied ] = useState( false );
+ const timer = useRef( null );
+
+ useEffect( () => () => clearTimeout( timer.current ), [] );
+
+ const copy = async () => {
+ try {
+ if ( navigator.clipboard?.writeText ) {
+ await navigator.clipboard.writeText( value );
+ } else {
+ const field = document.getElementById( id );
+ field.select();
+ document.execCommand( 'copy' );
+ }
+ setCopied( true );
+ clearTimeout( timer.current );
+ timer.current = setTimeout( () => setCopied( false ), 2000 );
+ } catch {
+ // Clipboard permission denied — the value stays selectable by hand.
+ }
+ };
+
+ return (
+
+
+
+ { copied
+ ? __( 'Copied', 'rave-payment-forms' )
+ : __( 'Copy', 'rave-payment-forms' ) }
+
+
+ );
+};
+
+/**
+ * Masked input with a Show/Hide toggle, used for the secret key.
+ *
+ * @param {Object} props Standard input props.
+ * @return {Element} The secret field.
+ */
+export const SecretInput = ( props ) => {
+ const [ visible, setVisible ] = useState( false );
+
+ return (
+
+
+ setVisible( ! visible ) }
+ >
+ { visible
+ ? __( 'Hide', 'rave-payment-forms' )
+ : __( 'Show', 'rave-payment-forms' ) }
+
+
+ );
+};
+
+/**
+ * Full-screen loading state shown between the last wizard step and the success
+ * screen, and while the initial settings request is in flight.
+ *
+ * @return {Element} The spinner.
+ */
+export const LoadingScreen = () => (
+
+
+
+);
+
+/**
+ * Dismissible toast used for save confirmations and errors.
+ *
+ * @param {Object} props
+ * @param {string} props.status `success` or `error`.
+ * @param {string} props.message Toast copy.
+ * @param {Function} props.onDismiss Dismiss handler.
+ * @return {Element} The toast.
+ */
+export const Toast = ( { status, message, onDismiss } ) => (
+
+ { 'success' === status && (
+
+
+
+ ) }
+ { message }
+
+ ×
+
+
+);
+
+/**
+ * On/off switch.
+ *
+ * @param {Object} props
+ * @param {boolean} props.checked Whether it is on.
+ * @param {boolean} props.disabled Whether it is busy.
+ * @param {string} props.label Accessible name.
+ * @param {Function} props.onChange Receives the new value.
+ * @return {Element} The switch.
+ */
+export const Switch = ( { checked, disabled, label, onChange } ) => (
+ onChange( ! checked ) }
+ >
+
+
+);
diff --git a/client/admin/components/list.js b/client/admin/components/list.js
new file mode 100644
index 0000000..067b44f
--- /dev/null
+++ b/client/admin/components/list.js
@@ -0,0 +1,478 @@
+/**
+ * Building blocks for the Payment Forms and Transactions screens: popovers,
+ * multi-selects, status pills, the details drawer, modal and pagination.
+ */
+
+import {
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { CheckIcon, ChevronDown } from '../icons';
+import { PRESETS, earliestDate, toIsoDate } from '../lib/dates';
+
+/**
+ * Close something when the user clicks outside it or presses Escape.
+ *
+ * @param {Object} ref Ref to the element.
+ * @param {boolean} active Whether to listen.
+ * @param {Function} onClose Close handler.
+ */
+export const useDismiss = ( ref, active, onClose ) => {
+ useEffect( () => {
+ if ( ! active ) {
+ return undefined;
+ }
+
+ const onPointer = ( event ) => {
+ if ( ref.current && ! ref.current.contains( event.target ) ) {
+ onClose();
+ }
+ };
+ const onKey = ( event ) => {
+ if ( 'Escape' === event.key ) {
+ onClose();
+ }
+ };
+
+ document.addEventListener( 'mousedown', onPointer );
+ document.addEventListener( 'keydown', onKey );
+
+ return () => {
+ document.removeEventListener( 'mousedown', onPointer );
+ document.removeEventListener( 'keydown', onKey );
+ };
+ }, [ ref, active, onClose ] );
+};
+
+/**
+ * A grey toolbar button with a trailing icon, as on the Flutterwave dashboard.
+ *
+ * @param {Object} props
+ * @param {*} props.icon Trailing icon.
+ * @param {*} props.children Label.
+ * @return {Element} The button.
+ */
+export const ToolbarButton = ( { icon, children, ...rest } ) => (
+
+ { children }
+ { icon }
+
+);
+
+/**
+ * A panel anchored under its trigger.
+ *
+ * @param {Object} props
+ * @param {boolean} props.open Whether it is shown.
+ * @param {Function} props.onClose Close handler.
+ * @param {*} props.trigger The trigger element.
+ * @param {string} props.label Accessible name.
+ * @param {*} props.children Panel body.
+ * @return {Element} The popover.
+ */
+export const Popover = ( { open, onClose, trigger, label, children } ) => {
+ const ref = useRef( null );
+ useDismiss( ref, open, onClose );
+
+ return (
+
+ { trigger }
+ { open && (
+
+ { children }
+
+ ) }
+
+ );
+};
+
+const PRESET_LABELS = {
+ today: __( 'Today', 'rave-payment-forms' ),
+ '7d': __( 'Last 7 days', 'rave-payment-forms' ),
+ '30d': __( '30 days', 'rave-payment-forms' ),
+ '1y': __( '1 year', 'rave-payment-forms' ),
+};
+
+/**
+ * Today / Last 7 days / 30 days / 1 year.
+ *
+ * @param {Object} props
+ * @param {string} props.value Active preset, or '' for a custom range.
+ * @param {Function} props.onChange Receives the preset.
+ * @return {Element} The preset row.
+ */
+export const DatePresets = ( { value, onChange } ) => (
+
+ { PRESETS.map( ( preset ) => (
+ onChange( preset ) }
+ >
+ { PRESET_LABELS[ preset ] }
+
+ ) ) }
+
+);
+
+/**
+ * Two date inputs limited to the last year.
+ *
+ * @param {Object} props
+ * @param {string} props.from Start date.
+ * @param {string} props.to End date.
+ * @param {Function} props.onChange Receives `{ from, to }`.
+ * @return {Element} The fields.
+ */
+export const DateRange = ( { from, to, onChange } ) => {
+ const min = earliestDate();
+ const max = toIsoDate( new Date() );
+
+ return (
+
+
+ onChange( { from: event.target.value, to } )
+ }
+ />
+
+ onChange( { from, to: event.target.value } )
+ }
+ />
+
+ );
+};
+
+/**
+ * Dropdown of checkboxes, optionally searchable.
+ *
+ * @param {Object} props
+ * @param {string} props.label Field label.
+ * @param {Array} props.options `{ value, label }` pairs.
+ * @param {string[]} props.value Selected values.
+ * @param {Function} props.onChange Receives the new selection.
+ * @param {string} props.placeholder Shown when nothing is selected.
+ * @param {boolean} [props.searchable] Type to filter options.
+ * @return {Element} The multi-select.
+ */
+export const MultiSelect = ( {
+ label,
+ options,
+ value,
+ onChange,
+ placeholder,
+ searchable,
+} ) => {
+ const [ open, setOpen ] = useState( false );
+ const [ query, setQuery ] = useState( '' );
+ const ref = useRef( null );
+ const id = useId();
+ useDismiss( ref, open, () => setOpen( false ) );
+
+ const visible = useMemo(
+ () =>
+ options.filter( ( option ) =>
+ option.label
+ .toLowerCase()
+ .includes( query.trim().toLowerCase() )
+ ),
+ [ options, query ]
+ );
+
+ const summary = value.length
+ ? options
+ .filter( ( option ) => value.includes( option.value ) )
+ .map( ( option ) => option.label )
+ .join( ', ' )
+ : '';
+
+ const toggle = ( optionValue ) =>
+ onChange(
+ value.includes( optionValue )
+ ? value.filter( ( item ) => item !== optionValue )
+ : [ ...value, optionValue ]
+ );
+
+ return (
+
+
+ { label }
+
+
+ { searchable ? (
+
setOpen( true ) }
+ onChange={ ( event ) => {
+ setQuery( event.target.value );
+ setOpen( true );
+ } }
+ />
+ ) : (
+
setOpen( ! open ) }
+ >
+
+ { summary || placeholder }
+
+
+ ) }
+
+
+
+ { open && (
+
+ ) }
+
+
+ );
+};
+
+const STATUS_LABELS = {
+ successful: __( 'Successful', 'rave-payment-forms' ),
+ pending: __( 'Pending', 'rave-payment-forms' ),
+ failed: __( 'Failed', 'rave-payment-forms' ),
+ cancelled: __( 'Cancelled', 'rave-payment-forms' ),
+ review: __( 'Needs review', 'rave-payment-forms' ),
+ publish: __( 'Published', 'rave-payment-forms' ),
+ draft: __( 'Draft', 'rave-payment-forms' ),
+ future: __( 'Scheduled', 'rave-payment-forms' ),
+ private: __( 'Private', 'rave-payment-forms' ),
+ synced: __( 'Synced pattern', 'rave-payment-forms' ),
+};
+
+export const statusLabel = ( status ) => STATUS_LABELS[ status ] || status;
+
+/**
+ * Coloured status label.
+ *
+ * @param {Object} props
+ * @param {string} props.status Status key.
+ * @param {string} [props.title] Full stored status, shown on hover.
+ * @return {Element} The pill.
+ */
+export const StatusPill = ( { status, title } ) => (
+
+ { statusLabel( status ) }
+
+);
+
+/**
+ * Previous / Next pagination.
+ *
+ * @param {Object} props
+ * @param {number} props.page Current page.
+ * @param {number} props.pages Total pages.
+ * @param {Function} props.onChange Receives the new page.
+ * @return {Element|null} The pager.
+ */
+export const Pagination = ( { page, pages, onChange } ) => {
+ if ( pages <= 1 ) {
+ return null;
+ }
+
+ return (
+
+ onChange( page - 1 ) }
+ >
+ { __( 'Previous', 'rave-payment-forms' ) }
+
+ = pages }
+ onClick={ () => onChange( page + 1 ) }
+ >
+ { __( 'Next', 'rave-payment-forms' ) }
+
+
+ { sprintf(
+ /* translators: 1: current page, 2: total pages. */
+ __( 'Page %1$d of %2$d', 'rave-payment-forms' ),
+ page,
+ pages
+ ) }
+
+
+ );
+};
+
+/**
+ * Right-hand slide-over panel.
+ *
+ * @param {Object} props
+ * @param {string} props.title Heading.
+ * @param {Function} props.onClose Close handler.
+ * @param {*} props.children Body.
+ * @param {*} [props.footer] Footer actions.
+ * @return {Element} The drawer.
+ */
+export const Drawer = ( { title, onClose, children, footer } ) => {
+ const ref = useRef( null );
+ useDismiss( ref, true, onClose );
+
+ return (
+
+
+
+ { children }
+ { footer && (
+
+ ) }
+
+
+ );
+};
+
+/**
+ * Centred dialog.
+ *
+ * @param {Object} props
+ * @param {string} props.title Heading.
+ * @param {Function} props.onClose Close handler.
+ * @param {*} props.children Body.
+ * @param {*} [props.footer] Footer actions.
+ * @return {Element} The modal.
+ */
+export const Modal = ( { title, onClose, children, footer } ) => {
+ const ref = useRef( null );
+ useDismiss( ref, true, onClose );
+
+ return (
+
+
+
+
{ children }
+ { footer && (
+
+ ) }
+
+
+ );
+};
+
+/**
+ * Definition list row for the details drawer.
+ *
+ * @param {Object} props
+ * @param {string} props.label Term.
+ * @param {*} props.children Value.
+ * @return {Element} The row.
+ */
+export const DetailRow = ( { label, children } ) => (
+
+
{ label }
+ { children || '—' }
+
+);
diff --git a/client/admin/components/tour.js b/client/admin/components/tour.js
new file mode 100644
index 0000000..1182024
--- /dev/null
+++ b/client/admin/components/tour.js
@@ -0,0 +1,536 @@
+/**
+ * Guided tips: a short tour per screen, and the Help menu that replays it or
+ * turns tips on and off.
+ *
+ * A screen calls useTour(), passes the result to in the breadcrumb
+ * bar, and renders once. Tours start by themselves the first time a
+ * screen is opened after onboarding, unless the user has turned tips off.
+ */
+
+import {
+ createPortal,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Button, Switch } from '.';
+import { Popover } from './list';
+import { ExternalIcon, HelpIcon } from '../icons';
+import { adminData, saveTour } from '../lib/api';
+import {
+ pickTourState,
+ placeTooltip,
+ shouldAutoStart,
+ tourKey,
+ tourSteps,
+} from '../lib/tour';
+
+/**
+ * Space left around the highlighted element.
+ */
+const SPOT_PADDING = 6;
+
+/**
+ * This browser's copy of a user's tour state.
+ *
+ * @param {number} userId User id.
+ * @return {string} Storage key.
+ */
+const storageKey = ( userId ) => `flw_tour_${ userId || 0 }`;
+
+/**
+ * Read the browser copy; storage can be missing or blocked.
+ *
+ * @param {number} userId User id.
+ * @return {Object|null} `{ enabled, seen, at }`.
+ */
+const readLocal = ( userId ) => {
+ try {
+ return JSON.parse(
+ window.localStorage.getItem( storageKey( userId ) )
+ );
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Write the browser copy.
+ *
+ * @param {number} userId User id.
+ * @param {Object} state `{ enabled, seen }`.
+ */
+const writeLocal = ( userId, state ) => {
+ try {
+ window.localStorage.setItem(
+ storageKey( userId ),
+ JSON.stringify( { ...state, at: Date.now() } )
+ );
+ } catch {
+ // Private windows and blocked storage fall back to the server copy.
+ }
+};
+
+/**
+ * Tour state for a screen.
+ *
+ * @param {string} screen Screen name, e.g. "transactions".
+ * @param {Object} [options]
+ * @param {boolean} [options.ready] Whether the screen has rendered what the steps point at.
+ * @param {boolean} [options.onboarded] Whether onboarding is finished; read from the page by default.
+ * @return {Object} `{ running, steps, enabled, start, finish, setEnabled }`.
+ */
+export const useTour = ( screen, { ready = true, onboarded } = {} ) => {
+ const data = adminData();
+ const [ state, setState ] = useState( () =>
+ pickTourState( data.tour, readLocal( data.userId ) )
+ );
+ const [ running, setRunning ] = useState( false );
+ const started = useRef( false );
+ const isOnboarded = undefined === onboarded ? data.onboarded : onboarded;
+
+ useEffect( () => {
+ if (
+ ! ready ||
+ started.current ||
+ ! shouldAutoStart( state, screen, isOnboarded )
+ ) {
+ return undefined;
+ }
+
+ // A short pause lets the page settle before the first tip appears.
+ const timer = setTimeout( () => {
+ started.current = true;
+ setRunning( true );
+ }, 500 );
+
+ return () => clearTimeout( timer );
+ }, [ ready, state, screen, isOnboarded ] );
+
+ // Keep the browser copy in step, so a page opened before the save lands agrees.
+ const update = ( next ) => {
+ setState( next );
+ writeLocal( data.userId, next );
+ };
+
+ const finish = () => {
+ const key = tourKey( screen );
+
+ setRunning( false );
+
+ if ( ! state.seen.includes( key ) ) {
+ update( { ...state, seen: [ ...state.seen, key ] } );
+ }
+
+ saveTour( { seen: key } ).catch( () => {} );
+ };
+
+ const setEnabled = ( enabled ) => {
+ started.current = true;
+
+ // Turning tips back on shows every page's tour again, starting with this one.
+ update( { enabled, seen: enabled ? [] : state.seen } );
+ setRunning( enabled );
+ saveTour( enabled ? { enabled, reset: true } : { enabled } ).catch(
+ () => {}
+ );
+ };
+
+ return {
+ running,
+ steps: tourSteps()[ screen ] || [],
+ enabled: state.enabled,
+ start: () => setRunning( true ),
+ finish,
+ setEnabled,
+ };
+};
+
+/**
+ * A rect as plain numbers, relative to the viewport.
+ *
+ * @param {Element} element Element.
+ * @return {Object} `{ top, left, width, height }`.
+ */
+const rectOf = ( element ) => {
+ const box = element.getBoundingClientRect();
+
+ return {
+ top: box.top,
+ left: box.left,
+ width: box.width,
+ height: box.height,
+ };
+};
+
+/**
+ * Whether an element is on screen and visible.
+ *
+ * @param {Element|null} element Element.
+ * @return {boolean} True when it has a size.
+ */
+const isShown = ( element ) => {
+ if ( ! element ) {
+ return false;
+ }
+
+ const box = element.getBoundingClientRect();
+
+ return box.width > 0 && box.height > 0;
+};
+
+/**
+ * The running tour: dims the page, highlights one element and explains it.
+ *
+ * @param {Object} props
+ * @param {Object} props.tour Result of useTour().
+ * @return {Element|null} The tour.
+ */
+export const Tour = ( { tour } ) => {
+ if ( ! tour.running || ! tour.steps.length ) {
+ return null;
+ }
+
+ return ;
+};
+
+/**
+ * @param {Object} props
+ * @param {Array} props.steps Steps.
+ * @param {Function} props.onFinish Called when the tour is finished or skipped.
+ * @return {Element|null} The current step.
+ */
+const TourSteps = ( { steps, onFinish } ) => {
+ const [ index, setIndex ] = useState( 0 );
+ const [ rect, setRect ] = useState( null );
+ const [ located, setLocated ] = useState( false );
+ const [ position, setPosition ] = useState( null );
+ const target = useRef( null );
+ const card = useRef( null );
+ const step = steps[ index ];
+
+ // The steps whose element is on the page right now: they decide "2 of 3",
+ // where Back and Next go, and when Next becomes Done.
+ const available = steps
+ .map( ( candidate, i ) => i )
+ .filter(
+ ( i ) =>
+ i === index ||
+ ! steps[ i ].target ||
+ isShown( document.querySelector( steps[ i ].target ) )
+ );
+ const place = available.indexOf( index );
+ const isLast = place === available.length - 1;
+
+ const go = useCallback(
+ ( next ) => {
+ if ( undefined === next || next >= steps.length ) {
+ onFinish();
+ return;
+ }
+
+ setLocated( false );
+ setPosition( null );
+ setIndex( next );
+ },
+ [ steps.length, onFinish ]
+ );
+ const next = () => go( isLast ? undefined : available[ place + 1 ] );
+ const back = () => place > 0 && go( available[ place - 1 ] );
+
+ // Find the element this step points at, giving the page a moment to render it.
+ useEffect( () => {
+ if ( ! step.target ) {
+ target.current = null;
+ setRect( null );
+ setLocated( true );
+ return undefined;
+ }
+
+ let tries = 0;
+ let timer;
+
+ const find = () => {
+ const element = document.querySelector( step.target );
+
+ if ( isShown( element ) ) {
+ const box = element.getBoundingClientRect();
+
+ if ( box.top < 0 || box.bottom > window.innerHeight ) {
+ element.scrollIntoView( { block: 'center' } );
+ }
+
+ target.current = element;
+ setRect( rectOf( element ) );
+ setLocated( true );
+ return;
+ }
+
+ tries += 1;
+
+ if ( tries < 8 ) {
+ timer = setTimeout( find, 150 );
+ return;
+ }
+
+ // Not on this page (an empty table, a menu hidden on a phone): skip it.
+ go( index + 1 );
+ };
+
+ find();
+
+ return () => clearTimeout( timer );
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per step.
+ }, [ index ] );
+
+ // Follow the element when the page scrolls or resizes.
+ useEffect( () => {
+ if ( ! located || ! target.current ) {
+ return undefined;
+ }
+
+ const update = () =>
+ target.current && setRect( rectOf( target.current ) );
+
+ window.addEventListener( 'resize', update );
+ window.addEventListener( 'scroll', update, true );
+
+ return () => {
+ window.removeEventListener( 'resize', update );
+ window.removeEventListener( 'scroll', update, true );
+ };
+ }, [ located ] );
+
+ // Measure the card, then place it beside the element.
+ useLayoutEffect( () => {
+ if ( ! located || ! card.current ) {
+ return;
+ }
+
+ setPosition(
+ placeTooltip(
+ rect,
+ {
+ width: card.current.offsetWidth,
+ height: card.current.offsetHeight,
+ },
+ { width: window.innerWidth, height: window.innerHeight },
+ step.placement
+ )
+ );
+ }, [ located, rect, step ] );
+
+ // Move focus to the card so keyboard and screen reader users follow along.
+ useEffect( () => {
+ if ( position ) {
+ card.current?.focus( { preventScroll: true } );
+ }
+ }, [ index, Boolean( position ) ] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect( () => {
+ const onKey = ( event ) => {
+ if ( 'Escape' === event.key ) {
+ onFinish();
+ } else if ( 'ArrowRight' === event.key ) {
+ next();
+ } else if ( 'ArrowLeft' === event.key ) {
+ back();
+ }
+ };
+
+ document.addEventListener( 'keydown', onKey );
+
+ return () => document.removeEventListener( 'keydown', onKey );
+ } );
+
+ if ( ! located ) {
+ return null;
+ }
+
+ const root =
+ document.getElementById( 'flutterwave-admin-root' ) || document.body;
+ const titleId = `flw-tour-title-${ index }`;
+
+ return createPortal(
+
+
+ { rect ? (
+
+ ) : (
+
+ ) }
+
+
+ { null !== position?.arrow && position && (
+
+ ) }
+
+ { available.length > 1 && (
+
+ { sprintf(
+ /* translators: 1: step number, 2: number of steps. */
+ __( '%1$d of %2$d', 'rave-payment-forms' ),
+ place + 1,
+ available.length
+ ) }
+
+ ) }
+
+ { step.title }
+
+
{ step.body }
+
+
+ { isLast ? (
+
+ ) : (
+
+ { __( 'Skip tour', 'rave-payment-forms' ) }
+
+ ) }
+
+ { place > 0 && (
+
+ { __( 'Back', 'rave-payment-forms' ) }
+
+ ) }
+
+ { isLast
+ ? __( 'Done', 'rave-payment-forms' )
+ : __( 'Next', 'rave-payment-forms' ) }
+
+
+
+
+
,
+ root
+ );
+};
+
+/**
+ * The Help menu in the breadcrumb bar.
+ *
+ * @param {Object} props
+ * @param {Object} props.tour Result of useTour().
+ * @return {Element} The menu.
+ */
+export const HelpMenu = ( { tour } ) => {
+ const [ open, setOpen ] = useState( false );
+ const close = () => setOpen( false );
+
+ return (
+ setOpen( ! open ) }
+ >
+
+ { __( 'Help', 'rave-payment-forms' ) }
+
+ }
+ >
+
+ { tour.steps.length > 0 && (
+
{
+ close();
+ tour.start();
+ } }
+ >
+
+ { __( 'Take the tour', 'rave-payment-forms' ) }
+
+
+ { __(
+ 'A quick walk through this page.',
+ 'rave-payment-forms'
+ ) }
+
+
+ ) }
+
+
+
+
+ { __( 'Show tips', 'rave-payment-forms' ) }
+
+
+ { __(
+ 'Guide me the first time I open each page.',
+ 'rave-payment-forms'
+ ) }
+
+
+ {
+ close();
+ tour.setEnabled( enabled );
+ } }
+ />
+
+
+ { adminData().documentation && (
+
+ { __(
+ 'Flutterwave documentation',
+ 'rave-payment-forms'
+ ) }
+
+
+ ) }
+
+
+ );
+};
diff --git a/client/admin/icons.js b/client/admin/icons.js
new file mode 100644
index 0000000..a8e84d8
--- /dev/null
+++ b/client/admin/icons.js
@@ -0,0 +1,449 @@
+/**
+ * Inline SVG marks and illustrations.
+ *
+ * The two welcome-card illustrations are the real Figma exports, loaded from
+ * assets/img/admin/. The remaining marks and the success artwork are still
+ * placeholders drawn to the right proportions and colours, so layout is final
+ * even though the artwork is stand-in. See assets/img/admin/README.md for how
+ * to swap the rest in.
+ */
+
+import { createElement as el } from '@wordpress/element';
+import { adminData } from './lib/api';
+
+const svg = ( props, ...children ) =>
+ el(
+ 'svg',
+ {
+ xmlns: 'http://www.w3.org/2000/svg',
+ focusable: 'false',
+ 'aria-hidden': 'true',
+ ...props,
+ },
+ ...children
+ );
+
+/**
+ * The official Flutterwave logo, served from assets/images/admin/.
+ *
+ * @return {Element} The logo.
+ */
+export const FlutterwaveWordmark = () =>
+ el( 'img', {
+ className: 'flw-wordmark',
+ src: adminData().assetsUrl + 'flutterwave-logo.svg',
+ alt: 'Flutterwave',
+ width: 168,
+ height: 40,
+ } );
+
+export const ChevronLeft = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '16', height: '16' },
+ el( 'path', {
+ d: 'M10 3 5 8l5 5',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.6',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+export const ChevronDown = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '16', height: '16' },
+ el( 'path', {
+ d: 'm4 6 4 4 4-4',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.6',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+export const InfoIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '15', height: '15' },
+ el( 'circle', {
+ cx: '8',
+ cy: '8',
+ r: '7',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.2',
+ } ),
+ el( 'path', {
+ d: 'M8 7.2v4',
+ stroke: 'currentColor',
+ strokeWidth: '1.4',
+ strokeLinecap: 'round',
+ } ),
+ el( 'circle', { cx: '8', cy: '4.9', r: '0.9', fill: 'currentColor' } )
+ );
+
+export const CheckIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '12', height: '12' },
+ el( 'path', {
+ d: 'm3.5 8.4 3 3 6-6.8',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '2',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+/* -------------------------------------------------------------------------- */
+/* Illustrations */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * The welcome cards use the exported Figma artwork. Each file is 370x190 and
+ * carries its own rounded background, so the card wrapper adds none of its own.
+ *
+ * @param {string} file Filename inside assets/img/admin/.
+ * @return {Function} An illustration component.
+ */
+const exported = ( file ) => () =>
+ el( 'img', {
+ className: 'flw-illustration',
+ src: adminData().assetsUrl + file,
+ alt: '',
+ width: 370,
+ height: 190,
+ loading: 'lazy',
+ } );
+
+export const DocsIllustration = exported( 'welcome-docs.svg' );
+
+export const SupportIllustration = exported( 'welcome-support.svg' );
+
+export const SuccessIllustration = () =>
+ svg(
+ { viewBox: '0 0 120 120', className: 'flw-success-art' },
+ el( 'circle', { cx: '60', cy: '60', r: '58', fill: '#FFF4E3' } ),
+ el( 'rect', {
+ x: '36',
+ y: '30',
+ width: '44',
+ height: '58',
+ rx: '6',
+ fill: '#FF7A00',
+ } ),
+ el( 'rect', {
+ x: '44',
+ y: '42',
+ width: '28',
+ height: '3.5',
+ rx: '1.75',
+ fill: '#FFE0B8',
+ } ),
+ el( 'rect', {
+ x: '44',
+ y: '52',
+ width: '28',
+ height: '3.5',
+ rx: '1.75',
+ fill: '#FFE0B8',
+ } ),
+ el( 'rect', {
+ x: '44',
+ y: '62',
+ width: '18',
+ height: '3.5',
+ rx: '1.75',
+ fill: '#FFE0B8',
+ } ),
+ el( 'circle', { cx: '82', cy: '82', r: '14', fill: '#12B76A' } ),
+ el( 'path', {
+ d: 'm76 82 4 4 8-8',
+ fill: 'none',
+ stroke: '#fff',
+ strokeWidth: '2.4',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+/* -------------------------------------------------------------------------- */
+/* Payment method marks */
+/* -------------------------------------------------------------------------- */
+
+const mark = ( ...children ) =>
+ svg( { viewBox: '0 0 48 32', className: 'flw-method-mark' }, ...children );
+
+export const METHOD_MARKS = {
+ card: () =>
+ mark(
+ el( 'rect', {
+ x: '6',
+ y: '8',
+ width: '36',
+ height: '17',
+ rx: '3',
+ fill: '#1D2939',
+ } ),
+ el( 'rect', {
+ x: '6',
+ y: '12.5',
+ width: '36',
+ height: '4',
+ fill: '#98A2B3',
+ } ),
+ el( 'rect', {
+ x: '9.5',
+ y: '19',
+ width: '10',
+ height: '2.5',
+ rx: '1.25',
+ fill: '#98A2B3',
+ } )
+ ),
+ stablecoin: () =>
+ mark(
+ el( 'circle', { cx: '19', cy: '16', r: '9', fill: '#26A17B' } ),
+ el( 'circle', {
+ cx: '29',
+ cy: '16',
+ r: '9',
+ fill: '#2775CA',
+ opacity: '0.9',
+ } ),
+ el( 'path', {
+ d: 'M26 12.5h6M29 12.5V20',
+ stroke: '#fff',
+ strokeWidth: '1.6',
+ strokeLinecap: 'round',
+ } )
+ ),
+ banktransfer: () =>
+ mark(
+ el( 'path', { d: 'M24 6 40 14H8L24 6Z', fill: '#1D2939' } ),
+ el( 'rect', {
+ x: '11',
+ y: '16',
+ width: '3.5',
+ height: '8',
+ fill: '#1D2939',
+ } ),
+ el( 'rect', {
+ x: '22.25',
+ y: '16',
+ width: '3.5',
+ height: '8',
+ fill: '#1D2939',
+ } ),
+ el( 'rect', {
+ x: '33.5',
+ y: '16',
+ width: '3.5',
+ height: '8',
+ fill: '#1D2939',
+ } ),
+ el( 'rect', {
+ x: '8',
+ y: '25',
+ width: '32',
+ height: '3',
+ rx: '1.5',
+ fill: '#1D2939',
+ } )
+ ),
+ mobilemoney: () =>
+ mark(
+ el( 'rect', {
+ x: '15',
+ y: '4',
+ width: '18',
+ height: '25',
+ rx: '3',
+ fill: '#1D2939',
+ } ),
+ el( 'rect', {
+ x: '17.5',
+ y: '8',
+ width: '13',
+ height: '15',
+ rx: '1.5',
+ fill: '#FFC46B',
+ } ),
+ el( 'circle', { cx: '24', cy: '26', r: '1.4', fill: '#98A2B3' } ),
+ el( 'path', {
+ d: 'M22 15.5h4M24 13.5v4',
+ stroke: '#1D2939',
+ strokeWidth: '1.4',
+ strokeLinecap: 'round',
+ } )
+ ),
+ applepay: () =>
+ mark(
+ el( 'rect', {
+ x: '2',
+ y: '4',
+ width: '44',
+ height: '24',
+ rx: '5',
+ fill: '#fff',
+ stroke: '#D0D5DD',
+ } ),
+ el( 'path', {
+ d: 'M16.4 12.2c-.5.6-1.3 1.1-2.1 1-.1-.8.3-1.7.8-2.2.5-.6 1.4-1 2.1-1.1.1.9-.3 1.7-.8 2.3Zm.8 1.2c-1.2-.1-2.2.7-2.7.7-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.8-1.2 2.1-.3 5.3.9 7 .6.9 1.3 1.8 2.2 1.8.9 0 1.2-.6 2.3-.6s1.4.6 2.3.6c1 0 1.6-.8 2.2-1.7.7-1 1-1.9 1-2-.1 0-1.9-.7-1.9-2.9 0-1.8 1.4-2.6 1.5-2.7-.8-1.2-2.1-1.3-2.6-1.4Z',
+ fill: '#111',
+ } ),
+ el(
+ 'text',
+ {
+ x: '24',
+ y: '21',
+ fill: '#111',
+ fontFamily: '-apple-system, Helvetica, Arial, sans-serif',
+ fontSize: '13',
+ fontWeight: '600',
+ },
+ 'Pay'
+ )
+ ),
+ googlepay: () =>
+ mark(
+ el( 'rect', {
+ x: '2',
+ y: '4',
+ width: '44',
+ height: '24',
+ rx: '5',
+ fill: '#fff',
+ stroke: '#D0D5DD',
+ } ),
+ el( 'path', {
+ d: 'M18.6 15.6v3h-1.5v-7.4h2.6c.6 0 1.2.2 1.6.7.5.4.7 1 .7 1.5 0 .6-.2 1.1-.7 1.5-.4.4-1 .7-1.6.7h-1.1Z',
+ fill: '#5F6368',
+ } ),
+ el( 'path', {
+ d: 'M14.4 14.4v1.3h2.2c-.1.6-.4 1-.8 1.3-.4.3-.9.5-1.4.5-1.2 0-2.2-1-2.2-2.2s1-2.2 2.2-2.2c.6 0 1.1.2 1.5.6l1-1a3.6 3.6 0 0 0-2.5-1 3.6 3.6 0 1 0 0 7.2c1 0 1.9-.3 2.5-1 .6-.6 .9-1.5 .9-2.5v-.6h-3.4Z',
+ fill: '#4285F4',
+ } ),
+ el(
+ 'text',
+ {
+ x: '26',
+ y: '20',
+ fill: '#5F6368',
+ fontFamily: 'Arial, sans-serif',
+ fontSize: '12',
+ fontWeight: '600',
+ },
+ 'Pay'
+ )
+ ),
+ opay: () =>
+ mark(
+ el( 'rect', {
+ x: '2',
+ y: '4',
+ width: '44',
+ height: '24',
+ rx: '5',
+ fill: '#fff',
+ stroke: '#D0D5DD',
+ } ),
+ el( 'circle', {
+ cx: '13',
+ cy: '16',
+ r: '5.5',
+ fill: 'none',
+ stroke: '#12B76A',
+ strokeWidth: '2.4',
+ } ),
+ el(
+ 'text',
+ {
+ x: '20',
+ y: '21',
+ fill: '#101B4C',
+ fontFamily: 'Arial, sans-serif',
+ fontSize: '12',
+ fontWeight: '700',
+ },
+ 'Pay'
+ )
+ ),
+};
+
+export const FilterIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '14', height: '14' },
+ el( 'path', {
+ d: 'M2.5 3h11l-4.25 5v4.25L6.75 13.5V8L2.5 3Z',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.4',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+export const DownloadIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '14', height: '14' },
+ el( 'path', {
+ d: 'M8 2.5v7.5m0 0L5 7m3 3 3-3M3 11.5V13h10v-1.5',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.4',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+export const PlusIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '14', height: '14' },
+ el( 'path', {
+ d: 'M8 3v10M3 8h10',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.6',
+ strokeLinecap: 'round',
+ } )
+ );
+
+export const ExternalIcon = () =>
+ svg(
+ { viewBox: '0 0 16 16', width: '12', height: '12' },
+ el( 'path', {
+ d: 'M9.5 2.5h4v4m0-4L7 9m-1-5.5H3.5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V10',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.3',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ } )
+ );
+
+export const HelpIcon = () =>
+ svg(
+ { viewBox: '0 0 20 20', width: '18', height: '18' },
+ el( 'circle', {
+ cx: '10',
+ cy: '10',
+ r: '8.25',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.5',
+ } ),
+ el( 'path', {
+ d: 'M7.75 7.6a2.3 2.3 0 0 1 4.45.8c0 1.5-2.2 1.9-2.2 3.1',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '1.5',
+ strokeLinecap: 'round',
+ } ),
+ el( 'circle', {
+ cx: '10',
+ cy: '14.4',
+ r: '0.95',
+ fill: 'currentColor',
+ } )
+ );
diff --git a/client/admin/index.js b/client/admin/index.js
new file mode 100644
index 0000000..4b2b7e8
--- /dev/null
+++ b/client/admin/index.js
@@ -0,0 +1,33 @@
+/**
+ * Mounts the Flutterwave admin app.
+ */
+
+import { createRoot } from '@wordpress/element';
+import App from './app';
+import PaymentForms from './screens/PaymentForms';
+import Transactions from './screens/Transactions';
+import Integrations from './screens/Integrations';
+import './admin.scss';
+
+const mount = () => {
+ const root = document.getElementById( 'flutterwave-admin-root' );
+
+ if ( ! root ) {
+ return;
+ }
+
+ const screens = {
+ forms: PaymentForms,
+ transactions: Transactions,
+ integrations: Integrations,
+ };
+ const Screen = screens[ root.dataset.screen ] || App;
+
+ createRoot( root ).render( );
+};
+
+if ( 'loading' === document.readyState ) {
+ document.addEventListener( 'DOMContentLoaded', mount );
+} else {
+ mount();
+}
diff --git a/client/admin/lib/api.js b/client/admin/lib/api.js
new file mode 100644
index 0000000..86e07ab
--- /dev/null
+++ b/client/admin/lib/api.js
@@ -0,0 +1,136 @@
+/**
+ * Thin wrapper over the plugin's REST endpoints.
+ *
+ * `wp-api-fetch` is enqueued as a dependency of the admin bundle, so the root
+ * URL and the REST nonce middleware are already registered by WordPress.
+ */
+
+import apiFetch from '@wordpress/api-fetch';
+
+const NAMESPACE = '/flutterwave/v1';
+
+export const fetchSettings = () =>
+ apiFetch( { path: `${ NAMESPACE }/settings` } );
+
+export const saveSettings = ( data ) =>
+ apiFetch( {
+ path: `${ NAMESPACE }/settings`,
+ method: 'POST',
+ data,
+ } );
+
+export const completeOnboarding = ( data = {} ) =>
+ apiFetch( {
+ path: `${ NAMESPACE }/onboarding/complete`,
+ method: 'POST',
+ data,
+ } );
+
+/**
+ * Values injected by the PHP page (doc/support links, asset base URL).
+ *
+ * @return {Object} Admin bootstrap data, or an empty object outside WP admin.
+ */
+export const adminData = () => window.flutterwaveAdminData || {};
+
+/**
+ * Query string for list filters. Arrays are sent comma separated.
+ *
+ * @param {Object} params Parameters.
+ * @return {string} Query string without the "?".
+ */
+export const toQuery = ( params ) =>
+ new URLSearchParams(
+ Object.entries( params )
+ .filter( ( [ , value ] ) =>
+ Array.isArray( value )
+ ? value.length
+ : '' !== value && undefined !== value && null !== value
+ )
+ .map( ( [ key, value ] ) => [
+ key,
+ Array.isArray( value ) ? value.join( ',' ) : String( value ),
+ ] )
+ ).toString();
+
+export const fetchPayments = ( params ) =>
+ apiFetch( { path: `${ NAMESPACE }/payments?${ toQuery( params ) }` } );
+
+export const verifyPayment = ( id ) =>
+ apiFetch( {
+ path: `${ NAMESPACE }/payments/${ id }/verify`,
+ method: 'POST',
+ } );
+
+export const deletePayment = ( id ) =>
+ apiFetch( { path: `${ NAMESPACE }/payments/${ id }`, method: 'DELETE' } );
+
+export const fetchForms = () => apiFetch( { path: `${ NAMESPACE }/forms` } );
+
+export const createFormPage = ( data ) =>
+ apiFetch( { path: `${ NAMESPACE }/forms/page`, method: 'POST', data } );
+
+/**
+ * Server-rendered HTML for a block, used by the form builder preview.
+ *
+ * @param {string} type Block type without the namespace.
+ * @param {Object} attributes Block attributes.
+ * @return {Promise} `{ rendered }`.
+ */
+export const renderBlock = ( type, attributes ) =>
+ apiFetch( {
+ path: `/wp/v2/block-renderer/flutterwave/${ type }?context=edit`,
+ method: 'POST',
+ data: { attributes },
+ } );
+
+export const fetchIntegrations = () =>
+ apiFetch( { path: `${ NAMESPACE }/integrations` } );
+
+export const setIntegration = ( id, enabled ) =>
+ apiFetch( {
+ path: `${ NAMESPACE }/integrations/${ id }`,
+ method: 'POST',
+ data: { enabled },
+ } );
+
+/**
+ * Install a plugin from WordPress.org and activate it, through core's REST API.
+ *
+ * @param {string} slug WordPress.org slug.
+ * @return {Promise} The plugin.
+ */
+export const installPlugin = ( slug ) =>
+ apiFetch( {
+ path: '/wp/v2/plugins',
+ method: 'POST',
+ data: { slug, status: 'active' },
+ } );
+
+/**
+ * Activate an installed plugin.
+ *
+ * @param {string} plugin Plugin id as core's REST API names it, e.g. "woocommerce/woocommerce".
+ * @return {Promise} The plugin.
+ */
+export const activatePlugin = ( plugin ) =>
+ apiFetch( {
+ path: `/wp/v2/plugins/${ plugin }`,
+ method: 'POST',
+ data: { status: 'active' },
+ } );
+
+/**
+ * Update the current user's guided tips: `{ enabled }`, `{ seen }` or `{ reset: true }`.
+ *
+ * @param {Object} data Changes.
+ * @return {Promise} The saved state.
+ */
+export const saveTour = ( data ) =>
+ apiFetch( {
+ path: `${ NAMESPACE }/tour`,
+ method: 'POST',
+ data,
+ // Finish saving even when the merchant closes a tip and leaves the page at once.
+ keepalive: true,
+ } );
diff --git a/client/admin/lib/constants.js b/client/admin/lib/constants.js
new file mode 100644
index 0000000..30e0879
--- /dev/null
+++ b/client/admin/lib/constants.js
@@ -0,0 +1,157 @@
+/**
+ * Shared copy and option lists for the admin app.
+ */
+
+import { __ } from '@wordpress/i18n';
+
+export const STEPS = [
+ { key: 'general', label: __( 'General details', 'rave-payment-forms' ) },
+ { key: 'api', label: __( 'API & webhook', 'rave-payment-forms' ) },
+ { key: 'methods', label: __( 'Payment methods', 'rave-payment-forms' ) },
+ { key: 'redirects', label: __( 'Redirects', 'rave-payment-forms' ) },
+];
+
+/**
+ * Field keys saved by each step or tab. Keep in step with FLW_Settings on the PHP side.
+ */
+export const STEP_FIELDS = {
+ general: [
+ 'title',
+ 'description',
+ 'logoUrl',
+ 'buttonText',
+ 'currency',
+ 'country',
+ 'donationTitle',
+ 'donationDescription',
+ ],
+ api: [ 'publicKey', 'secretKey', 'secretHash' ],
+ methods: [ 'paymentMethods', 'advancedMethods', 'useThemeStyle' ],
+ redirects: [ 'successUrl', 'failedUrl', 'pendingUrl' ],
+};
+
+export const CURRENCY_OPTIONS = [
+ {
+ value: 'any',
+ label: __( 'Let customers choose', 'rave-payment-forms' ),
+ },
+ ...[
+ 'NGN',
+ 'GHS',
+ 'KES',
+ 'USD',
+ 'GBP',
+ 'EUR',
+ 'ZAR',
+ 'TZS',
+ 'UGX',
+ 'RWF',
+ 'ZMW',
+ ].map( ( code ) => ( { value: code, label: code } ) ),
+];
+
+export const COUNTRY_OPTIONS = [
+ { value: 'NG', label: __( 'Nigeria', 'rave-payment-forms' ) },
+ { value: 'GH', label: __( 'Ghana', 'rave-payment-forms' ) },
+ { value: 'KE', label: __( 'Kenya', 'rave-payment-forms' ) },
+ { value: 'ZA', label: __( 'South Africa', 'rave-payment-forms' ) },
+ { value: 'TZ', label: __( 'Tanzania', 'rave-payment-forms' ) },
+ { value: 'UG', label: __( 'Uganda', 'rave-payment-forms' ) },
+ { value: 'RW', label: __( 'Rwanda', 'rave-payment-forms' ) },
+ { value: 'ZM', label: __( 'Zambia', 'rave-payment-forms' ) },
+ { value: 'US', label: __( 'Worldwide', 'rave-payment-forms' ) },
+];
+
+/**
+ * The checkbox list on the "Payment methods" step. `key` maps to the groups in
+ * FLW_Settings::METHOD_MAP on the PHP side.
+ */
+export const PAYMENT_METHODS = [
+ {
+ key: 'card',
+ label: __( 'Cards (credit/debit cards)', 'rave-payment-forms' ),
+ description: __(
+ 'Pay securely using your Visa, Mastercard, or American Express.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'stablecoin',
+ label: __( 'Stablecoin', 'rave-payment-forms' ),
+ description: __(
+ 'Pay with USDT or USDC. Fast, global, and low-fee.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'banktransfer',
+ label: __( 'Bank Transfer', 'rave-payment-forms' ),
+ description: __(
+ "Transfer funds directly from your bank account using your bank's transfer service.",
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'mobilemoney',
+ label: __( 'Mobile Money', 'rave-payment-forms' ),
+ description: __(
+ 'Quick and secure payment directly from your mobile wallet.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'applepay',
+ label: __( 'Apple Pay', 'rave-payment-forms' ),
+ description: __(
+ 'Check out in seconds using your Apple Pay wallet.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'googlepay',
+ label: __( 'Google Pay', 'rave-payment-forms' ),
+ description: __(
+ 'Simple and fast checkout using the cards saved to your Google Account.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ key: 'opay',
+ label: __( 'Opay', 'rave-payment-forms' ),
+ description: __(
+ 'Pay directly from your OPay wallet or card.',
+ 'rave-payment-forms'
+ ),
+ },
+];
+
+/**
+ * Flutterwave payment options without a checkbox. Kept editable under
+ * "Advanced" so upgrading merchants never silently lose them.
+ */
+export const ADVANCED_METHODS = [
+ { key: 'ussd', label: __( 'USSD', 'rave-payment-forms' ) },
+ { key: 'qr', label: __( 'QR', 'rave-payment-forms' ) },
+ { key: 'nqr', label: __( 'NQR', 'rave-payment-forms' ) },
+ { key: 'credit', label: __( 'Credit', 'rave-payment-forms' ) },
+ { key: 'barter', label: __( 'Barter', 'rave-payment-forms' ) },
+];
+
+export const TOOLTIPS = {
+ title: __(
+ 'Shown at the top of the Flutterwave payment page. Defaults to your site name.',
+ 'rave-payment-forms'
+ ),
+ currency: __(
+ 'Forms charge in this currency unless a shortcode sets its own.',
+ 'rave-payment-forms'
+ ),
+ publicKey: __(
+ 'Your Flutterwave public key. Use a test key (FLWPUBK_TEST-…) to try payments without real money.',
+ 'rave-payment-forms'
+ ),
+ secretKey: __(
+ 'Your Flutterwave secret key, used to verify payments. It must be from the same mode as the public key.',
+ 'rave-payment-forms'
+ ),
+};
diff --git a/client/admin/lib/dates.js b/client/admin/lib/dates.js
new file mode 100644
index 0000000..ab09af0
--- /dev/null
+++ b/client/admin/lib/dates.js
@@ -0,0 +1,121 @@
+/**
+ * Date helpers for the Transactions filters. Dates are YYYY-MM-DD strings in the
+ * browser's local time, which is what uses.
+ */
+
+/**
+ * A Date as YYYY-MM-DD in local time.
+ *
+ * @param {Date} date The date.
+ * @return {string} ISO date.
+ */
+export const toIsoDate = ( date ) => {
+ const pad = ( value ) => String( value ).padStart( 2, '0' );
+ return `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad(
+ date.getDate()
+ ) }`;
+};
+
+/**
+ * Parse YYYY-MM-DD as a local date.
+ *
+ * @param {string} value ISO date.
+ * @return {Date|null} The date, or null when invalid.
+ */
+export const fromIsoDate = ( value ) => {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec( String( value || '' ) );
+
+ if ( ! match ) {
+ return null;
+ }
+
+ const date = new Date(
+ Number( match[ 1 ] ),
+ Number( match[ 2 ] ) - 1,
+ Number( match[ 3 ] )
+ );
+
+ return date.getMonth() === Number( match[ 2 ] ) - 1 ? date : null;
+};
+
+export const PRESETS = [ 'today', '7d', '30d', '1y' ];
+
+/**
+ * The date range for a preset, ending today.
+ *
+ * @param {string} preset One of PRESETS.
+ * @param {Date} [now] Today; injectable for tests.
+ * @return {{from: string, to: string}} The range.
+ */
+export const presetRange = ( preset, now = new Date() ) => {
+ const to = new Date( now.getFullYear(), now.getMonth(), now.getDate() );
+ const from = new Date( to );
+
+ switch ( preset ) {
+ case '7d':
+ from.setDate( from.getDate() - 7 );
+ break;
+ case '30d':
+ from.setDate( from.getDate() - 30 );
+ break;
+ case '1y':
+ from.setFullYear( from.getFullYear() - 1 );
+ break;
+ }
+
+ return { from: toIsoDate( from ), to: toIsoDate( to ) };
+};
+
+/**
+ * The earliest date a filter may start: one year before today.
+ *
+ * @param {Date} [now] Today; injectable for tests.
+ * @return {string} ISO date.
+ */
+export const earliestDate = ( now = new Date() ) =>
+ presetRange( '1y', now ).from;
+
+/**
+ * Keep a custom range inside the last year and in order.
+ *
+ * @param {string} from Start date.
+ * @param {string} to End date.
+ * @param {Date} [now] Today; injectable for tests.
+ * @return {{from: string, to: string}} The corrected range.
+ */
+export const clampRange = ( from, to, now = new Date() ) => {
+ const today = toIsoDate( now );
+ const earliest = earliestDate( now );
+ let start = fromIsoDate( from ) ? from : earliest;
+ let end = fromIsoDate( to ) ? to : today;
+
+ if ( end > today ) {
+ end = today;
+ }
+ if ( start < earliest ) {
+ start = earliest;
+ }
+ if ( start > end ) {
+ [ start, end ] = [ end, start ];
+ }
+
+ return { from: start, to: end };
+};
+
+/**
+ * Display a date like "Sep 24, 2026".
+ *
+ * @param {string} value ISO date.
+ * @return {string} Formatted date.
+ */
+export const formatDate = ( value ) => {
+ const date = fromIsoDate( value );
+
+ return date
+ ? date.toLocaleDateString( undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ } )
+ : '';
+};
diff --git a/client/admin/lib/integrations.js b/client/admin/lib/integrations.js
new file mode 100644
index 0000000..e738380
--- /dev/null
+++ b/client/admin/lib/integrations.js
@@ -0,0 +1,155 @@
+/**
+ * What an integration card offers next, worked out from the state the
+ * /integrations route reports.
+ */
+
+import { __, sprintf } from '@wordpress/i18n';
+
+export const CATEGORIES = {
+ ecommerce: __( 'E-commerce', 'rave-payment-forms' ),
+ digital: __( 'Digital products', 'rave-payment-forms' ),
+ donations: __( 'Donations', 'rave-payment-forms' ),
+ forms: __( 'Forms', 'rave-payment-forms' ),
+ memberships: __( 'Memberships', 'rave-payment-forms' ),
+ courses: __( 'Courses', 'rave-payment-forms' ),
+ events: __( 'Events', 'rave-payment-forms' ),
+};
+
+/**
+ * The step a plugin needs: installing, activating, or nothing.
+ *
+ * @param {Object} plugin `{ name, slug, rest, state }`.
+ * @param {Object} caps Capabilities.
+ * @param {boolean} caps.canInstall Whether plugins can be installed.
+ * @param {boolean} caps.canActivate Whether plugins can be activated.
+ * @return {Object|null} The step, or null when the plugin is active.
+ */
+const pluginStep = ( plugin, { canInstall, canActivate } ) => {
+ if ( 'active' === plugin.state ) {
+ return null;
+ }
+
+ if ( 'inactive' === plugin.state ) {
+ return canActivate
+ ? {
+ action: 'activate',
+ target: plugin.rest,
+ label: sprintf(
+ /* translators: %s: plugin name. */
+ __( 'Activate %s', 'rave-payment-forms' ),
+ plugin.name
+ ),
+ }
+ : {
+ action: 'blocked',
+ label: sprintf(
+ /* translators: %s: plugin name. */
+ __(
+ 'Ask an administrator to activate %s.',
+ 'rave-payment-forms'
+ ),
+ plugin.name
+ ),
+ };
+ }
+
+ return canInstall
+ ? {
+ action: 'install',
+ target: plugin.slug,
+ label: sprintf(
+ /* translators: %s: plugin name. */
+ __( 'Install %s', 'rave-payment-forms' ),
+ plugin.name
+ ),
+ }
+ : {
+ action: 'blocked',
+ label: sprintf(
+ /* translators: %s: plugin name. */
+ __( '%s is not installed.', 'rave-payment-forms' ),
+ plugin.name
+ ),
+ };
+};
+
+/**
+ * The next thing a merchant can do with an integration.
+ *
+ * @param {Object} item Integration from the /integrations route.
+ * @param {Object} caps `{ canInstall, canActivate }`.
+ * @return {Object} `{ action, label, target? }`, where action is one of
+ * install, activate, enable, manage, blocked or soon.
+ */
+export const nextStep = ( item, caps ) => {
+ if ( 'coming-soon' === item.kind ) {
+ return {
+ action: 'soon',
+ label: __( 'Coming soon', 'rave-payment-forms' ),
+ };
+ }
+
+ const host = pluginStep( item.host, caps );
+
+ if ( host ) {
+ return host;
+ }
+
+ if ( 'extension' === item.kind ) {
+ const extension = pluginStep( item.extension, caps );
+
+ if ( extension ) {
+ return extension;
+ }
+ }
+
+ if ( 'built-in' === item.kind && ! item.enabled ) {
+ return {
+ action: 'enable',
+ label: __( 'Enable', 'rave-payment-forms' ),
+ };
+ }
+
+ return { action: 'manage', label: __( 'Manage', 'rave-payment-forms' ) };
+};
+
+/**
+ * The status pill for a card.
+ *
+ * @param {Object} item Integration.
+ * @return {Object} `{ tone, label }`.
+ */
+export const cardStatus = ( item ) => {
+ if ( 'coming-soon' === item.kind ) {
+ return {
+ tone: 'soon',
+ label: __( 'Coming soon', 'rave-payment-forms' ),
+ };
+ }
+
+ if ( item.enabled ) {
+ return { tone: 'active', label: __( 'Active', 'rave-payment-forms' ) };
+ }
+
+ if ( 'missing' === item.host.state ) {
+ return {
+ tone: 'idle',
+ label: __( 'Not installed', 'rave-payment-forms' ),
+ };
+ }
+
+ return {
+ tone: 'ready',
+ label: __( 'Ready to connect', 'rave-payment-forms' ),
+ };
+};
+
+/**
+ * Whether the host plugin's currency cannot be charged through Flutterwave.
+ *
+ * @param {Object} item Integration.
+ * @param {string[]} currencies Currencies Flutterwave supports here.
+ * @return {boolean} True when the currency is known and unsupported.
+ */
+export const unsupportedCurrency = ( item, currencies ) =>
+ Boolean( item.currency ) && ! currencies.includes( item.currency );
diff --git a/client/admin/lib/money.js b/client/admin/lib/money.js
new file mode 100644
index 0000000..7aae686
--- /dev/null
+++ b/client/admin/lib/money.js
@@ -0,0 +1,24 @@
+/**
+ * Money formatting shared by the admin screens.
+ */
+
+/**
+ * "NGN 98,000.00" style amounts.
+ *
+ * @param {number} amount Amount.
+ * @param {string} currency ISO currency code.
+ * @param {Object} [options]
+ * @param {boolean} [options.trimZeros] Drop ".00" on whole amounts.
+ * @return {string} The formatted amount.
+ */
+export const formatMoney = ( amount, currency, { trimZeros = false } = {} ) => {
+ const value = Number( amount ) || 0;
+ const whole = Number.isInteger( value );
+ const digits = trimZeros && whole ? 0 : 2;
+ const number = value.toLocaleString( 'en-US', {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
+ } );
+
+ return currency ? `${ currency } ${ number }` : number;
+};
diff --git a/client/admin/lib/shortcode.js b/client/admin/lib/shortcode.js
new file mode 100644
index 0000000..c69c4bb
--- /dev/null
+++ b/client/admin/lib/shortcode.js
@@ -0,0 +1,70 @@
+/**
+ * Turn form builder settings into a shortcode, for merchants who do not use
+ * the block editor. Pricing cards and payment method badges are blocks only.
+ */
+
+/**
+ * Remove characters that would end a shortcode attribute or tag.
+ *
+ * @param {*} value Attribute value.
+ * @return {string} Safe value.
+ */
+const clean = ( value ) =>
+ String( value ?? '' )
+ .replace( /["[\]]/g, '' )
+ .trim();
+
+/**
+ * Build `name="value"` pairs, skipping empty values.
+ *
+ * @param {Object} attributes Attributes.
+ * @return {string} Attribute string with a leading space, or ''.
+ */
+const attrs = ( attributes ) =>
+ Object.entries( attributes )
+ .map( ( [ key, value ] ) => [ key, clean( value ) ] )
+ .filter( ( [ , value ] ) => '' !== value && '0' !== value )
+ .map( ( [ key, value ] ) => ` ${ key }="${ value }"` )
+ .join( '' );
+
+/**
+ * The shortcode for a form, or null when the type has no shortcode.
+ *
+ * @param {string} type Block type (e.g. "payment-button").
+ * @param {Object} attributes Builder attributes.
+ * @return {string|null} The shortcode.
+ */
+export const toShortcode = ( type, attributes ) => {
+ const a = attributes || {};
+
+ switch ( type ) {
+ case 'payment-button': {
+ const text = clean( a.buttonText ) || 'Pay {amount}';
+ return `[flw-pay-form${ attrs( {
+ amount: a.amount,
+ currency: a.currency,
+ layout: 'compact',
+ exclude: 'fullname,phone',
+ } ) }]${ text }[/flw-pay-form]`;
+ }
+ case 'payment-form': {
+ const open = `[flw-pay-form${ attrs( {
+ heading: a.heading,
+ description: a.description,
+ amount: a.amount,
+ currency: a.currency,
+ } ) }]`;
+ const text = clean( a.buttonText );
+ return text ? `${ open }${ text }[/flw-pay-form]` : open;
+ }
+ case 'donation-form':
+ return `[flw-donation-form${ attrs( {
+ heading: a.heading,
+ message: a.message,
+ amounts: a.amounts,
+ currency: a.currency,
+ } ) }]`;
+ }
+
+ return null;
+};
diff --git a/client/admin/lib/test/admin-lib.test.js b/client/admin/lib/test/admin-lib.test.js
new file mode 100644
index 0000000..7fe7811
--- /dev/null
+++ b/client/admin/lib/test/admin-lib.test.js
@@ -0,0 +1,87 @@
+/**
+ * External dependencies
+ */
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import { clampRange, earliestDate, presetRange } from '../dates';
+import { formatMoney } from '../money';
+import { toShortcode } from '../shortcode';
+
+const NOW = new Date( 2026, 8, 24 ); // Sep 24, 2026.
+
+describe( 'presetRange', () => {
+ it.each( [
+ [ 'today', '2026-09-24', '2026-09-24' ],
+ [ '7d', '2026-09-17', '2026-09-24' ],
+ [ '30d', '2026-08-25', '2026-09-24' ],
+ [ '1y', '2025-09-24', '2026-09-24' ],
+ ] )( '%s', ( preset, from, to ) => {
+ expect( presetRange( preset, NOW ) ).toEqual( { from, to } );
+ } );
+} );
+
+describe( 'clampRange', () => {
+ it( 'keeps ranges inside the last year and in order', () => {
+ expect( clampRange( '2020-01-01', '2026-01-01', NOW ) ).toEqual( {
+ from: earliestDate( NOW ),
+ to: '2026-01-01',
+ } );
+ expect( clampRange( '2026-09-01', '2026-08-01', NOW ) ).toEqual( {
+ from: '2026-08-01',
+ to: '2026-09-01',
+ } );
+ expect( clampRange( '2026-09-01', '2027-01-01', NOW ).to ).toBe(
+ '2026-09-24'
+ );
+ expect( clampRange( 'bad', '', NOW ) ).toEqual(
+ presetRange( '1y', NOW )
+ );
+ } );
+} );
+
+describe( 'formatMoney', () => {
+ it( 'formats like the Flutterwave dashboard', () => {
+ expect( formatMoney( 98000, 'NGN' ) ).toBe( 'NGN 98,000.00' );
+ expect( formatMoney( 602474.29, 'NGN' ) ).toBe( 'NGN 602,474.29' );
+ expect( formatMoney( 5000, 'NGN', { trimZeros: true } ) ).toBe(
+ 'NGN 5,000'
+ );
+ expect( formatMoney( 12.5, '', { trimZeros: true } ) ).toBe( '12.50' );
+ } );
+} );
+
+describe( 'toShortcode', () => {
+ it( 'builds a compact payment button', () => {
+ expect(
+ toShortcode( 'payment-button', { amount: 5000, currency: 'NGN' } )
+ ).toBe(
+ '[flw-pay-form amount="5000" currency="NGN" layout="compact" exclude="fullname,phone"]Pay {amount}[/flw-pay-form]'
+ );
+ } );
+
+ it( 'drops empty values and characters that would break the tag', () => {
+ expect(
+ toShortcode( 'payment-form', {
+ heading: 'Pay "now" [today]',
+ amount: 0,
+ currency: '',
+ } )
+ ).toBe( '[flw-pay-form heading="Pay now today"]' );
+ } );
+
+ it( 'builds a donation form', () => {
+ expect(
+ toShortcode( 'donation-form', {
+ amounts: '1000, 5000',
+ currency: 'KES',
+ } )
+ ).toBe( '[flw-donation-form amounts="1000, 5000" currency="KES"]' );
+ } );
+
+ it( 'has no shortcode for block-only types', () => {
+ expect( toShortcode( 'pricing-card', {} ) ).toBeNull();
+ } );
+} );
diff --git a/client/admin/lib/test/integrations.test.js b/client/admin/lib/test/integrations.test.js
new file mode 100644
index 0000000..b806b68
--- /dev/null
+++ b/client/admin/lib/test/integrations.test.js
@@ -0,0 +1,118 @@
+/**
+ * External dependencies
+ */
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import { cardStatus, nextStep, unsupportedCurrency } from '../integrations';
+
+const ALL = { canInstall: true, canActivate: true };
+const NONE = { canInstall: false, canActivate: false };
+
+const plugin = ( state, name = 'Host' ) => ( {
+ name,
+ slug: name.toLowerCase(),
+ rest: `${ name.toLowerCase() }/${ name.toLowerCase() }`,
+ state,
+} );
+
+const builtIn = ( state, enabled = false ) => ( {
+ kind: 'built-in',
+ host: plugin( state ),
+ enabled,
+} );
+
+const extension = ( hostState, extensionState ) => ( {
+ kind: 'extension',
+ host: plugin( hostState, 'WooCommerce' ),
+ extension: plugin( extensionState, 'Extension' ),
+ enabled: 'active' === hostState && 'active' === extensionState,
+} );
+
+describe( 'nextStep', () => {
+ it( 'installs a missing host plugin', () => {
+ expect( nextStep( builtIn( 'missing' ), ALL ) ).toEqual( {
+ action: 'install',
+ target: 'host',
+ label: 'Install Host',
+ } );
+ } );
+
+ it( 'activates an inactive host plugin', () => {
+ expect( nextStep( builtIn( 'inactive' ), ALL ) ).toMatchObject( {
+ action: 'activate',
+ target: 'host/host',
+ } );
+ } );
+
+ it( 'is blocked without the capabilities', () => {
+ expect( nextStep( builtIn( 'missing' ), NONE ).action ).toBe(
+ 'blocked'
+ );
+ expect( nextStep( builtIn( 'inactive' ), NONE ).action ).toBe(
+ 'blocked'
+ );
+ } );
+
+ it( 'enables a built-in integration once the host is active', () => {
+ expect( nextStep( builtIn( 'active' ), ALL ).action ).toBe( 'enable' );
+ expect( nextStep( builtIn( 'active', true ), ALL ).action ).toBe(
+ 'manage'
+ );
+ } );
+
+ it( 'needs WooCommerce before the extension', () => {
+ expect(
+ nextStep( extension( 'missing', 'missing' ), ALL )
+ ).toMatchObject( {
+ action: 'install',
+ target: 'woocommerce',
+ } );
+ expect(
+ nextStep( extension( 'active', 'missing' ), ALL )
+ ).toMatchObject( {
+ action: 'install',
+ target: 'extension',
+ } );
+ expect(
+ nextStep( extension( 'active', 'inactive' ), ALL )
+ ).toMatchObject( {
+ action: 'activate',
+ target: 'extension/extension',
+ } );
+ expect( nextStep( extension( 'active', 'active' ), ALL ).action ).toBe(
+ 'manage'
+ );
+ } );
+
+ it( 'marks catalogue entries that are not ready', () => {
+ expect( nextStep( { kind: 'coming-soon' }, ALL ).action ).toBe(
+ 'soon'
+ );
+ } );
+} );
+
+describe( 'cardStatus', () => {
+ it( 'describes each state', () => {
+ expect( cardStatus( { kind: 'coming-soon' } ).tone ).toBe( 'soon' );
+ expect( cardStatus( builtIn( 'active', true ) ).tone ).toBe( 'active' );
+ expect( cardStatus( builtIn( 'missing' ) ).tone ).toBe( 'idle' );
+ expect( cardStatus( builtIn( 'inactive' ) ).tone ).toBe( 'ready' );
+ } );
+} );
+
+describe( 'unsupportedCurrency', () => {
+ it( 'flags a currency Flutterwave does not charge', () => {
+ expect(
+ unsupportedCurrency( { currency: 'JPY' }, [ 'NGN', 'USD' ] )
+ ).toBe( true );
+ expect(
+ unsupportedCurrency( { currency: 'USD' }, [ 'NGN', 'USD' ] )
+ ).toBe( false );
+ expect( unsupportedCurrency( { currency: '' }, [ 'NGN' ] ) ).toBe(
+ false
+ );
+ } );
+} );
diff --git a/client/admin/lib/test/tour.test.js b/client/admin/lib/test/tour.test.js
new file mode 100644
index 0000000..95fbfcf
--- /dev/null
+++ b/client/admin/lib/test/tour.test.js
@@ -0,0 +1,165 @@
+/**
+ * External dependencies
+ */
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import {
+ pickTourState,
+ placeTooltip,
+ shouldAutoStart,
+ tourKey,
+ tourSteps,
+ TOUR_VERSION,
+} from '../tour';
+
+const VIEWPORT = { width: 1280, height: 800 };
+const CARD = { width: 320, height: 180 };
+
+describe( 'shouldAutoStart', () => {
+ it( 'starts once per screen and version after onboarding', () => {
+ const fresh = { enabled: true, seen: [] };
+
+ expect( shouldAutoStart( fresh, 'forms', true ) ).toBe( true );
+ expect( shouldAutoStart( fresh, 'forms', false ) ).toBe( false );
+ expect(
+ shouldAutoStart(
+ { enabled: true, seen: [ tourKey( 'forms' ) ] },
+ 'forms',
+ true
+ )
+ ).toBe( false );
+ expect(
+ shouldAutoStart(
+ { enabled: true, seen: [ 'forms@0' ] },
+ 'forms',
+ true
+ )
+ ).toBe( true );
+ } );
+
+ it( 'never starts when tips are off', () => {
+ expect(
+ shouldAutoStart( { enabled: false, seen: [] }, 'forms', true )
+ ).toBe( false );
+ expect( shouldAutoStart( undefined, 'forms', true ) ).toBe( false );
+ } );
+
+ it( 'keys tours by version', () => {
+ expect( tourKey( 'transactions' ) ).toBe(
+ `transactions@${ TOUR_VERSION }`
+ );
+ } );
+} );
+
+describe( 'placeTooltip', () => {
+ it( 'centres a step without a target', () => {
+ expect( placeTooltip( null, CARD, VIEWPORT ) ).toEqual( {
+ top: 310,
+ left: 480,
+ side: 'center',
+ arrow: null,
+ } );
+ } );
+
+ it( 'sits below the target with the arrow on its centre', () => {
+ const target = { top: 100, left: 600, width: 80, height: 40 };
+
+ expect( placeTooltip( target, CARD, VIEWPORT ) ).toEqual( {
+ top: 154,
+ left: 480,
+ side: 'bottom',
+ arrow: 160,
+ } );
+ } );
+
+ it( 'flips above when there is no room below', () => {
+ const target = { top: 700, left: 600, width: 80, height: 40 };
+
+ expect( placeTooltip( target, CARD, VIEWPORT ).side ).toBe( 'top' );
+ expect( placeTooltip( target, CARD, VIEWPORT ).top ).toBe( 506 );
+ } );
+
+ it( 'stays inside the viewport near an edge', () => {
+ const target = { top: 100, left: 1240, width: 30, height: 30 };
+ const placed = placeTooltip( target, CARD, VIEWPORT );
+
+ expect( placed.left ).toBe( 1280 - 320 - 16 );
+ expect( placed.arrow ).toBe( 304 );
+ } );
+
+ it( 'goes right of admin menu links, or below when it cannot', () => {
+ const link = { top: 300, left: 0, width: 160, height: 30 };
+
+ expect( placeTooltip( link, CARD, VIEWPORT, 'right' ) ).toMatchObject( {
+ side: 'right',
+ left: 174,
+ arrow: 90,
+ } );
+ expect(
+ placeTooltip( link, CARD, { width: 360, height: 800 }, 'right' )
+ .side
+ ).toBe( 'bottom' );
+ } );
+} );
+
+describe( 'tourSteps', () => {
+ it( 'has a short tour for every screen', () => {
+ const steps = tourSteps();
+
+ for ( const screen of [
+ 'settings',
+ 'forms',
+ 'transactions',
+ 'integrations',
+ ] ) {
+ expect( steps[ screen ].length ).toBeGreaterThan( 1 );
+ expect( steps[ screen ].length ).toBeLessThanOrEqual( 6 );
+ steps[ screen ].forEach( ( step ) => {
+ expect( step.title ).toBeTruthy();
+ expect( step.body ).toBeTruthy();
+ } );
+ }
+ } );
+} );
+
+describe( 'pickTourState', () => {
+ const server = { enabled: true, seen: [ 'forms@1' ], updated: 1000 };
+
+ it( 'uses the server copy by default', () => {
+ expect( pickTourState( server, null ) ).toEqual( {
+ enabled: true,
+ seen: [ 'forms@1' ],
+ } );
+ expect( pickTourState( undefined, null ) ).toEqual( {
+ enabled: true,
+ seen: [],
+ } );
+ } );
+
+ it( 'prefers a newer browser copy that the server has not caught up with', () => {
+ const local = {
+ enabled: true,
+ seen: [ 'forms@1', 'transactions@1' ],
+ at: 1001000,
+ };
+
+ expect( pickTourState( server, local ).seen ).toContain(
+ 'transactions@1'
+ );
+ } );
+
+ it( 'ignores an older or malformed browser copy', () => {
+ expect(
+ pickTourState( server, { enabled: false, seen: [], at: 999000 } )
+ ).toEqual( {
+ enabled: true,
+ seen: [ 'forms@1' ],
+ } );
+ expect(
+ pickTourState( server, { enabled: false, at: 5000000 } ).enabled
+ ).toBe( true );
+ } );
+} );
diff --git a/client/admin/lib/test/validate.test.js b/client/admin/lib/test/validate.test.js
new file mode 100644
index 0000000..b0be0d7
--- /dev/null
+++ b/client/admin/lib/test/validate.test.js
@@ -0,0 +1,142 @@
+/**
+ * External dependencies
+ */
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import {
+ formatErrors,
+ isHttpUrl,
+ keyMode,
+ validateApi,
+ validateGeneral,
+ validateMethods,
+ validateRedirects,
+} from '../validate';
+
+describe( 'validateGeneral', () => {
+ it( 'requires a title and a currency', () => {
+ expect( Object.keys( validateGeneral( {} ) ) ).toEqual( [
+ 'title',
+ 'currency',
+ ] );
+ } );
+
+ it( 'passes with a title and currency', () => {
+ expect( validateGeneral( { title: 'Acme', currency: 'NGN' } ) ).toEqual(
+ {}
+ );
+ } );
+
+ it( 'rejects a logo that is not an http(s) URL', () => {
+ expect(
+ validateGeneral( {
+ title: 'Acme',
+ currency: 'NGN',
+ logoUrl: 'javascript:alert(1)',
+ } )
+ ).toHaveProperty( 'logoUrl' );
+ } );
+} );
+
+describe( 'keyMode', () => {
+ it.each( [
+ [ 'FLWPUBK_TEST-abc-X', 'FLWPUBK', 'test' ],
+ [ 'FLWPUBK-abc-X', 'FLWPUBK', 'live' ],
+ [ 'FLWSECK_TEST-abc-X', 'FLWSECK', 'test' ],
+ [ 'FLWSECK_TEST-abc-X', 'FLWPUBK', '' ],
+ [ 'pk_live_123', 'FLWPUBK', '' ],
+ [ undefined, 'FLWPUBK', '' ],
+ ] )( '%s with prefix %s is %s', ( key, prefix, expected ) => {
+ expect( keyMode( key, prefix ) ).toBe( expected );
+ } );
+} );
+
+describe( 'validateApi', () => {
+ it( 'requires both keys', () => {
+ expect( Object.keys( validateApi( {} ) ) ).toEqual( [
+ 'publicKey',
+ 'secretKey',
+ ] );
+ } );
+
+ it( 'rejects keys that are not Flutterwave keys', () => {
+ const errors = validateApi( {
+ publicKey: 'pk_test_1',
+ secretKey: 'sk_test_1',
+ } );
+
+ expect( errors.publicKey ).toMatch( /FLWPUBK/ );
+ expect( errors.secretKey ).toMatch( /FLWSECK/ );
+ } );
+
+ it( 'rejects a test public key with a live secret key', () => {
+ expect(
+ validateApi( {
+ publicKey: 'FLWPUBK_TEST-a-X',
+ secretKey: 'FLWSECK-a-X',
+ } )
+ ).toEqual( { secretKey: expect.stringMatching( /same mode/ ) } );
+ } );
+
+ it( 'passes with a matching pair', () => {
+ expect(
+ validateApi( {
+ publicKey: 'FLWPUBK-a-X',
+ secretKey: 'FLWSECK-a-X',
+ } )
+ ).toEqual( {} );
+ } );
+} );
+
+describe( 'validateMethods', () => {
+ it( 'requires at least one method', () => {
+ expect( validateMethods( {} ) ).toHaveProperty( 'paymentMethods' );
+ } );
+
+ it( 'accepts an advanced method on its own', () => {
+ expect( validateMethods( { advancedMethods: [ 'ussd' ] } ) ).toEqual(
+ {}
+ );
+ } );
+} );
+
+describe( 'validateRedirects', () => {
+ it( 'requires three http(s) URLs', () => {
+ expect(
+ validateRedirects( {
+ successUrl: 'https://shop.test/thanks',
+ failedUrl: '/relative',
+ pendingUrl: '',
+ } )
+ ).toEqual( {
+ failedUrl: expect.any( String ),
+ pendingUrl: expect.any( String ),
+ } );
+ } );
+} );
+
+describe( 'isHttpUrl', () => {
+ it.each( [
+ [ 'https://shop.test', true ],
+ [ 'http://localhost:8888/', true ],
+ [ 'ftp://shop.test', false ],
+ [ 'shop.test', false ],
+ [ '', false ],
+ ] )( '%s → %s', ( value, expected ) => {
+ expect( isHttpUrl( value ) ).toBe( expected );
+ } );
+} );
+
+describe( 'formatErrors', () => {
+ it( 'keeps errors only for fields that have a value', () => {
+ const values = { publicKey: 'nope', secretKey: '' };
+ const found = { publicKey: 'bad format', secretKey: 'required' };
+
+ expect( formatErrors( found, values ) ).toEqual( {
+ publicKey: 'bad format',
+ } );
+ } );
+} );
diff --git a/client/admin/lib/tour.js b/client/admin/lib/tour.js
new file mode 100644
index 0000000..bff8294
--- /dev/null
+++ b/client/admin/lib/tour.js
@@ -0,0 +1,299 @@
+/**
+ * Guided tips: the steps for each screen, and where to place the tooltip.
+ *
+ * Bump TOUR_VERSION when the screens change enough to be worth showing again:
+ * tours are remembered as "screen@version", so a new version reappears once.
+ */
+
+import { __ } from '@wordpress/i18n';
+
+export const TOUR_VERSION = 1;
+
+/**
+ * A link in the Flutterwave admin menu.
+ *
+ * @param {string} page Page slug.
+ * @return {string} Selector.
+ */
+const menuLink = ( page ) => `#adminmenu a[href$="page=${ page }"]`;
+
+/**
+ * Steps per screen. A step without a target is shown in the middle of the page;
+ * a step whose target is missing or hidden (for example the admin menu on a
+ * phone, or a table with no rows yet) is skipped.
+ *
+ * @return {Object} Steps keyed by screen.
+ */
+export const tourSteps = () => ( {
+ settings: [
+ {
+ title: __(
+ 'Welcome to your Flutterwave dashboard',
+ 'rave-payment-forms'
+ ),
+ body: __(
+ 'You’re set up to take payments. Here’s a one-minute look at what you can do next.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="settings-tabs"]',
+ title: __( 'Your settings', 'rave-payment-forms' ),
+ body: __(
+ 'Update your business details, API keys, payment methods and redirects here, one tab at a time.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: menuLink( 'flutterwave-payment-forms' ),
+ placement: 'right',
+ title: __( 'Payment Forms', 'rave-payment-forms' ),
+ body: __(
+ 'Create payment buttons, forms, donation forms and pricing cards, and see the pages they’re on.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: menuLink( 'flutterwave-payments-transactions' ),
+ placement: 'right',
+ title: __( 'Transactions', 'rave-payment-forms' ),
+ body: __(
+ 'Every payment in one place. Filter by date, status and currency, and download a CSV.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: menuLink( 'flutterwave-payments-integrations' ),
+ placement: 'right',
+ title: __( 'Integrations', 'rave-payment-forms' ),
+ body: __(
+ 'Take Flutterwave payments in WooCommerce, Easy Digital Downloads and GiveWP.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="help"]',
+ title: __( 'Help is always here', 'rave-payment-forms' ),
+ body: __(
+ 'Replay a tour or turn tips off from this menu at any time.',
+ 'rave-payment-forms'
+ ),
+ },
+ ],
+ forms: [
+ {
+ target: '[data-tour="new-form"]',
+ title: __( 'Create a form', 'rave-payment-forms' ),
+ body: __(
+ 'Pick a payment button, form, donation form or pricing card, preview it, and we’ll put it on a new page for you.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="form-types"]',
+ title: __( 'Filter by type', 'rave-payment-forms' ),
+ body: __(
+ 'Every block and shortcode on your site is listed here, whichever way you added it.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="form-row"]',
+ title: __( 'See what each form earns', 'rave-payment-forms' ),
+ body: __(
+ 'Each row shows the page a form is on, whether it’s live, and the payments it has taken.',
+ 'rave-payment-forms'
+ ),
+ },
+ ],
+ transactions: [
+ {
+ target: '[data-tour="filter"]',
+ title: __( 'Find a payment', 'rave-payment-forms' ),
+ body: __(
+ 'Narrow the list by date, status or currency. The last year is shown to start with.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="download"]',
+ title: __( 'Download for your records', 'rave-payment-forms' ),
+ body: __(
+ 'Export any date range as a CSV file for your spreadsheet or accountant.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="transaction-row"]',
+ title: __( 'Open a transaction', 'rave-payment-forms' ),
+ body: __(
+ 'Click a row for the full details. You can re-check a pending payment with Flutterwave from there.',
+ 'rave-payment-forms'
+ ),
+ },
+ ],
+ integrations: [
+ {
+ target: '[data-tour="integrations-available"]',
+ title: __( 'Connect your plugins', 'rave-payment-forms' ),
+ body: __(
+ 'Use Flutterwave in the store or donation plugin you already run. Install, activate or switch on each one from its card.',
+ 'rave-payment-forms'
+ ),
+ },
+ {
+ target: '[data-tour="integrations-soon"]',
+ title: __( 'More on the way', 'rave-payment-forms' ),
+ body: __(
+ 'These are coming next. Tell us which plugin you’d like us to add from the link at the bottom of the page.',
+ 'rave-payment-forms'
+ ),
+ },
+ ],
+} );
+
+/**
+ * The key a finished tour is remembered by.
+ *
+ * @param {string} screen Screen name.
+ * @return {string} e.g. "transactions@1".
+ */
+export const tourKey = ( screen ) => `${ screen }@${ TOUR_VERSION }`;
+
+/**
+ * Whether a screen's tour should start by itself.
+ *
+ * @param {Object} state `{ enabled, seen }` for the current user.
+ * @param {string} screen Screen name.
+ * @param {boolean} onboarded Whether onboarding is finished.
+ * @return {boolean} True the first time a screen is opened with tips on.
+ */
+export const shouldAutoStart = ( state, screen, onboarded ) =>
+ Boolean(
+ onboarded &&
+ state?.enabled &&
+ ! state.seen?.includes( tourKey( screen ) )
+ );
+
+/**
+ * The newer of the server's tour state and the copy kept in this browser.
+ *
+ * Saving happens in the background, so a merchant who closes a tip and moves
+ * straight to another page can load it before the save lands. The browser copy
+ * covers that gap; the server's copy wins when it is newer (another browser).
+ *
+ * @param {Object} server `{ enabled, seen, updated }`, updated in seconds.
+ * @param {Object|null} local `{ enabled, seen, at }`, at in milliseconds.
+ * @return {Object} `{ enabled, seen }`.
+ */
+export const pickTourState = ( server, local ) => {
+ const fromServer = {
+ enabled: server?.enabled !== false,
+ seen: Array.isArray( server?.seen ) ? server.seen : [],
+ };
+
+ if (
+ ! local ||
+ ! Array.isArray( local.seen ) ||
+ ! ( local.at / 1000 > ( server?.updated || 0 ) )
+ ) {
+ return fromServer;
+ }
+
+ return { enabled: local.enabled !== false, seen: local.seen };
+};
+
+const GAP = 14;
+const MARGIN = 16;
+
+/**
+ * Keep a value inside a range.
+ *
+ * @param {number} value Value.
+ * @param {number} min Lower bound.
+ * @param {number} max Upper bound.
+ * @return {number} Clamped value.
+ */
+const clamp = ( value, min, max ) =>
+ Math.min( Math.max( value, min ), Math.max( min, max ) );
+
+/**
+ * Where to put the tooltip next to its target.
+ *
+ * Prefers the requested side and flips when there is not enough room, then
+ * keeps the card inside the viewport. The arrow points at the target's centre.
+ *
+ * @param {Object} target Target rect `{ top, left, width, height }`, or null to centre.
+ * @param {Object} card Card size `{ width, height }`.
+ * @param {Object} viewport Viewport size `{ width, height }`.
+ * @param {string} placement "bottom" (default), "top" or "right".
+ * @return {Object} `{ top, left, side, arrow }`, where arrow is the offset along the card edge.
+ */
+export const placeTooltip = (
+ target,
+ card,
+ viewport,
+ placement = 'bottom'
+) => {
+ if ( ! target ) {
+ return {
+ top: Math.max( MARGIN, ( viewport.height - card.height ) / 2 ),
+ left: Math.max( MARGIN, ( viewport.width - card.width ) / 2 ),
+ side: 'center',
+ arrow: null,
+ };
+ }
+
+ const centreX = target.left + target.width / 2;
+ const centreY = target.top + target.height / 2;
+ const fitsBelow =
+ target.top + target.height + GAP + card.height <=
+ viewport.height - MARGIN;
+ const fitsAbove = target.top - GAP - card.height >= MARGIN;
+ const fitsRight =
+ target.left + target.width + GAP + card.width <=
+ viewport.width - MARGIN;
+
+ let side = placement;
+
+ if ( 'right' === side && ! fitsRight ) {
+ side = 'bottom';
+ }
+
+ if ( 'bottom' === side && ! fitsBelow && fitsAbove ) {
+ side = 'top';
+ } else if ( 'top' === side && ! fitsAbove && fitsBelow ) {
+ side = 'bottom';
+ }
+
+ if ( 'right' === side ) {
+ const top = clamp(
+ centreY - card.height / 2,
+ MARGIN,
+ viewport.height - card.height - MARGIN
+ );
+
+ return {
+ top,
+ left: target.left + target.width + GAP,
+ side,
+ arrow: clamp( centreY - top, 16, card.height - 16 ),
+ };
+ }
+
+ const left = clamp(
+ centreX - card.width / 2,
+ MARGIN,
+ viewport.width - card.width - MARGIN
+ );
+
+ return {
+ top:
+ 'top' === side
+ ? target.top - GAP - card.height
+ : target.top + target.height + GAP,
+ left,
+ side,
+ arrow: clamp( centreX - left, 16, card.width - 16 ),
+ };
+};
diff --git a/client/admin/lib/validate.js b/client/admin/lib/validate.js
new file mode 100644
index 0000000..8a02c65
--- /dev/null
+++ b/client/admin/lib/validate.js
@@ -0,0 +1,168 @@
+/**
+ * Per-step validation. Each function returns a map of field key to message;
+ * an empty object means the step is complete.
+ */
+
+import { __ } from '@wordpress/i18n';
+
+const blank = ( value ) => ! String( value || '' ).trim();
+
+/**
+ * Whether a value is an absolute http(s) URL.
+ *
+ * @param {string} value Candidate URL.
+ * @return {boolean} True when valid.
+ */
+export const isHttpUrl = ( value ) => {
+ try {
+ const url = new URL( String( value || '' ).trim() );
+ return 'http:' === url.protocol || 'https:' === url.protocol;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * The mode a Flutterwave key belongs to.
+ *
+ * @param {string} key The key.
+ * @param {string} prefix `FLWPUBK` or `FLWSECK`.
+ * @return {string} `test`, `live`, or an empty string when the key is not recognised.
+ */
+export const keyMode = ( key, prefix ) => {
+ const value = String( key || '' ).trim();
+
+ if ( value.startsWith( `${ prefix }_TEST-` ) ) {
+ return 'test';
+ }
+
+ return value.startsWith( `${ prefix }-` ) ? 'live' : '';
+};
+
+/**
+ * The errors worth showing before the merchant has tried to continue: format
+ * problems in fields they have filled in, not "required" errors for empty ones.
+ *
+ * @param {Object} found Errors from a validator.
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const formatErrors = ( found, values ) =>
+ Object.fromEntries(
+ Object.entries( found ).filter(
+ ( [ key ] ) => ! blank( values[ key ] )
+ )
+ );
+
+/**
+ * Step 1 needs a title and a default currency.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateGeneral = ( values ) => {
+ const errors = {};
+
+ if ( blank( values.title ) ) {
+ errors.title = __(
+ 'Enter a title customers will see when they pay.',
+ 'rave-payment-forms'
+ );
+ }
+
+ if ( blank( values.currency ) ) {
+ errors.currency = __(
+ 'Choose the currency your forms charge in.',
+ 'rave-payment-forms'
+ );
+ }
+
+ if ( ! blank( values.logoUrl ) && ! isHttpUrl( values.logoUrl ) ) {
+ errors.logoUrl = __(
+ 'Enter a full URL, starting with https://.',
+ 'rave-payment-forms'
+ );
+ }
+
+ return errors;
+};
+
+/**
+ * Step 2 needs a matching public and secret key pair.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateApi = ( values ) => {
+ const errors = {};
+ const publicMode = keyMode( values.publicKey, 'FLWPUBK' );
+ const secretMode = keyMode( values.secretKey, 'FLWSECK' );
+
+ if ( blank( values.publicKey ) ) {
+ errors.publicKey = __( 'Enter your public key.', 'rave-payment-forms' );
+ } else if ( ! publicMode ) {
+ errors.publicKey = __(
+ 'Public keys start with FLWPUBK_TEST- or FLWPUBK-.',
+ 'rave-payment-forms'
+ );
+ }
+
+ if ( blank( values.secretKey ) ) {
+ errors.secretKey = __( 'Enter your secret key.', 'rave-payment-forms' );
+ } else if ( ! secretMode ) {
+ errors.secretKey = __(
+ 'Secret keys start with FLWSECK_TEST- or FLWSECK-.',
+ 'rave-payment-forms'
+ );
+ } else if ( publicMode && publicMode !== secretMode ) {
+ errors.secretKey = __(
+ 'Use a secret key from the same mode (test or live) as your public key.',
+ 'rave-payment-forms'
+ );
+ }
+
+ return errors;
+};
+
+/**
+ * Step 3 needs at least one method, otherwise checkout would offer nothing.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateMethods = ( values ) => {
+ const errors = {};
+ const selected =
+ ( values.paymentMethods || [] ).length +
+ ( values.advancedMethods || [] ).length;
+
+ if ( 0 === selected ) {
+ errors.paymentMethods = __(
+ 'Select at least one payment method.',
+ 'rave-payment-forms'
+ );
+ }
+
+ return errors;
+};
+
+/**
+ * Step 4 needs somewhere to send customers after each outcome.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateRedirects = ( values ) => {
+ const errors = {};
+
+ [ 'successUrl', 'failedUrl', 'pendingUrl' ].forEach( ( key ) => {
+ if ( ! isHttpUrl( values[ key ] ) ) {
+ errors[ key ] = __(
+ 'Enter a full URL, starting with https://.',
+ 'rave-payment-forms'
+ );
+ }
+ } );
+
+ return errors;
+};
diff --git a/client/admin/screens/FormBuilder.js b/client/admin/screens/FormBuilder.js
new file mode 100644
index 0000000..1f75f7e
--- /dev/null
+++ b/client/admin/screens/FormBuilder.js
@@ -0,0 +1,450 @@
+/**
+ * "New form" builder: pick a type, fill in a few fields with a live preview,
+ * then create a draft page with the block or copy the shortcode.
+ */
+
+import { useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import {
+ Button,
+ CopyField,
+ Field,
+ Select,
+ TextArea,
+ TextInput,
+} from '../components';
+import { Modal } from '../components/list';
+import { adminData, createFormPage, renderBlock } from '../lib/api';
+import { toShortcode } from '../lib/shortcode';
+import { FORM_TYPES } from './form-types';
+
+const DEFAULTS = {
+ 'payment-button': { amount: 5000, buttonText: '', useUserEmail: false },
+ 'payment-form': { heading: '', description: '', amount: 0, buttonText: '' },
+ 'donation-form': { heading: '', message: '', amounts: '1000, 5000, 10000' },
+ 'pricing-card': {
+ planName: 'Starter',
+ amount: 25000,
+ features: [],
+ badge: '',
+ },
+};
+
+const PAGE_TITLES = {
+ 'payment-button': __( 'Buy now', 'rave-payment-forms' ),
+ 'payment-form': __( 'Make a payment', 'rave-payment-forms' ),
+ 'donation-form': __( 'Donate', 'rave-payment-forms' ),
+ 'pricing-card': __( 'Pricing', 'rave-payment-forms' ),
+};
+
+/**
+ * Amount input showing 0 as empty.
+ *
+ * @param {Object} props
+ * @param {string} props.id Control id.
+ * @param {number} props.value Amount.
+ * @param {Function} props.onChange Receives the number.
+ * @return {Element} The input.
+ */
+const AmountInput = ( { id, value, onChange } ) => (
+ 0 ? String( value ) : '' }
+ placeholder={ __( 'Customer enters the amount', 'rave-payment-forms' ) }
+ onChange={ ( event ) =>
+ onChange( Math.max( 0, parseFloat( event.target.value ) || 0 ) )
+ }
+ />
+);
+
+/**
+ * The fields for one form type.
+ *
+ * @param {Object} props
+ * @param {string} props.type Form type.
+ * @param {Object} props.values Attributes.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @return {Element} The fields.
+ */
+const TypeFields = ( { type, values, setValue } ) => {
+ const { currencies = [], siteCurrency } = adminData();
+ const currency = (
+
+ ( {
+ value: code,
+ label: code,
+ } ) ) }
+ onChange={ ( event ) =>
+ setValue( 'currency', event.target.value )
+ }
+ />
+
+ );
+ const text = ( key, label, placeholder ) => (
+
+ setValue( key, event.target.value ) }
+ />
+
+ );
+ const amount = ( label ) => (
+
+ setValue( 'amount', next ) }
+ />
+
+ );
+
+ switch ( type ) {
+ case 'payment-button':
+ return (
+ <>
+
+ { amount( __( 'Amount', 'rave-payment-forms' ) ) }
+ { currency }
+
+ { text(
+ 'buttonText',
+ __( 'Button text', 'rave-payment-forms' ),
+ __( 'Pay {amount}', 'rave-payment-forms' )
+ ) }
+ >
+ );
+ case 'payment-form':
+ return (
+ <>
+ { text(
+ 'heading',
+ __( 'Heading', 'rave-payment-forms' ),
+ __( 'Pay an invoice', 'rave-payment-forms' )
+ ) }
+
+ { amount(
+ __(
+ 'Fixed amount (optional)',
+ 'rave-payment-forms'
+ )
+ ) }
+ { currency }
+
+ { text(
+ 'buttonText',
+ __( 'Button text', 'rave-payment-forms' ),
+ __( 'Proceed to Flutterwave', 'rave-payment-forms' )
+ ) }
+ >
+ );
+ case 'donation-form':
+ return (
+ <>
+ { text(
+ 'heading',
+ __( 'Heading', 'rave-payment-forms' ),
+ __( 'Support our work', 'rave-payment-forms' )
+ ) }
+
+
+
+ { text(
+ 'amounts',
+ __( 'Suggested amounts', 'rave-payment-forms' ),
+ '1000, 5000, 10000'
+ ) }
+ { currency }
+
+ >
+ );
+ case 'pricing-card':
+ return (
+ <>
+ { text(
+ 'planName',
+ __( 'Plan name', 'rave-payment-forms' ),
+ 'Starter'
+ ) }
+
+ { amount( __( 'Price', 'rave-payment-forms' ) ) }
+ { currency }
+
+
+
+ { text(
+ 'badge',
+ __( 'Badge (optional)', 'rave-payment-forms' ),
+ __( 'Popular', 'rave-payment-forms' )
+ ) }
+ >
+ );
+ }
+
+ return null;
+};
+
+/**
+ * Server-rendered preview of the block, refreshed shortly after each change.
+ *
+ * @param {Object} props
+ * @param {string} props.type Form type.
+ * @param {Object} props.attributes Attributes.
+ * @return {Element} The preview.
+ */
+const Preview = ( { type, attributes } ) => {
+ const [ html, setHtml ] = useState( '' );
+ const [ failed, setFailed ] = useState( false );
+
+ useEffect( () => {
+ let cancelled = false;
+ const timer = setTimeout( () => {
+ const clean = {
+ ...attributes,
+ features: ( attributes.features || [] ).filter( Boolean ),
+ };
+ renderBlock( type, 'pricing-card' === type ? clean : attributes )
+ .then( ( response ) => {
+ if ( ! cancelled ) {
+ setHtml( response?.rendered || '' );
+ setFailed( false );
+ }
+ } )
+ .catch( () => ! cancelled && setFailed( true ) );
+ }, 350 );
+
+ return () => {
+ cancelled = true;
+ clearTimeout( timer );
+ };
+ }, [ type, attributes ] );
+
+ if ( failed ) {
+ return (
+
+ { __( 'Preview unavailable.', 'rave-payment-forms' ) }
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+/**
+ * @param {Object} props
+ * @param {Function} props.onClose Close handler.
+ * @param {Function} props.onNotice `( status, message ) => void`.
+ * @return {Element} The builder modal.
+ */
+const FormBuilder = ( { onClose, onNotice } ) => {
+ const [ type, setType ] = useState( '' );
+ const [ values, setValues ] = useState( {} );
+ const [ title, setTitle ] = useState( '' );
+ const [ busy, setBusy ] = useState( false );
+
+ const choose = ( key ) => {
+ setType( key );
+ setValues( DEFAULTS[ key ] );
+ setTitle( PAGE_TITLES[ key ] );
+ };
+
+ const setValue = ( key, value ) =>
+ setValues( ( current ) => ( { ...current, [ key ]: value } ) );
+ const shortcode = type ? toShortcode( type, values ) : null;
+
+ const create = async () => {
+ setBusy( true );
+ try {
+ const attributes = { ...values };
+ if ( attributes.features ) {
+ attributes.features = attributes.features.filter( Boolean );
+ }
+ const page = await createFormPage( {
+ title,
+ block: type,
+ attributes,
+ } );
+ window.location.assign( page.editUrl );
+ } catch ( error ) {
+ setBusy( false );
+ onNotice(
+ 'error',
+ error?.message ||
+ __( 'Could not create the page.', 'rave-payment-forms' )
+ );
+ }
+ };
+
+ if ( ! type ) {
+ return (
+
+
+ { __(
+ 'What would you like to add to your site?',
+ 'rave-payment-forms'
+ ) }
+
+
+ { FORM_TYPES.filter( ( item ) => item.buildable ).map(
+ ( item ) => (
+ choose( item.key ) }
+ >
+
+ { item.icon }
+
+
+ { item.label }
+
+
+ { item.description }
+
+
+ )
+ ) }
+
+
+ );
+ }
+
+ const current = FORM_TYPES.find( ( item ) => item.key === type );
+
+ return (
+
+ setType( '' ) }
+ >
+ { __( 'Back', 'rave-payment-forms' ) }
+
+
+ { __( 'Create page', 'rave-payment-forms' ) }
+
+ >
+ }
+ >
+
+
+
+
+ setTitle( event.target.value )
+ }
+ />
+
+
+
+ { shortcode && (
+
+ ) }
+
+
+
+
+ { __( 'Preview', 'rave-payment-forms' ) }
+
+
+
+
+
+ );
+};
+
+export default FormBuilder;
diff --git a/client/admin/screens/Integrations.js b/client/admin/screens/Integrations.js
new file mode 100644
index 0000000..1d8dd2d
--- /dev/null
+++ b/client/admin/screens/Integrations.js
@@ -0,0 +1,437 @@
+/**
+ * Integrations: connect Flutterwave to the plugins merchants already use to
+ * sell, take donations and run memberships.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Page, Button, Switch, Toast } from '../components';
+import { HelpMenu, Tour, useTour } from '../components/tour';
+import { ExternalIcon } from '../icons';
+import {
+ activatePlugin,
+ adminData,
+ fetchIntegrations,
+ installPlugin,
+ setIntegration,
+} from '../lib/api';
+import {
+ CATEGORIES,
+ cardStatus,
+ nextStep,
+ unsupportedCurrency,
+} from '../lib/integrations';
+
+/**
+ * Tile colours for each integration's monogram.
+ */
+const TILES = {
+ woocommerce: [ '#f3ecfb', '#6b3fa0' ],
+ 'easy-digital-downloads': [ '#e8f1fb', '#1d5c96' ],
+ givewp: [ '#e9f7ee', '#1f7a45' ],
+};
+
+/**
+ * Initials for a monogram tile, e.g. "ED" for Easy Digital Downloads.
+ *
+ * @param {string} name Plugin name.
+ * @return {string} Up to two letters.
+ */
+const initials = ( name ) =>
+ name
+ .split( /[\s-]+/ )
+ .filter( Boolean )
+ .slice( 0, 2 )
+ .map( ( word ) => word[ 0 ].toUpperCase() )
+ .join( '' );
+
+/**
+ * @param {Object} props
+ * @param {Object} props.item The integration.
+ * @return {Element} The monogram tile.
+ */
+const Monogram = ( { item } ) => {
+ const [ background, color ] = TILES[ item.id ] || [ '#f2f4f7', '#475467' ];
+
+ return (
+
+ { initials( item.name ) }
+
+ );
+};
+
+/**
+ * One integration card.
+ *
+ * @param {Object} props
+ * @param {Object} props.item Integration.
+ * @param {Object} props.caps `{ canInstall, canActivate }`.
+ * @param {string[]} props.currencies Supported currencies.
+ * @param {boolean} props.busy Whether an action is running on it.
+ * @param {Function} props.onAction `( item, step ) => void`.
+ * @param {Function} props.onToggle `( item, enabled ) => void`.
+ * @return {Element} The card.
+ */
+const IntegrationCard = ( {
+ item,
+ caps,
+ currencies,
+ busy,
+ onAction,
+ onToggle,
+} ) => {
+ const step = nextStep( item, caps );
+ const status = cardStatus( item );
+ const soon = 'soon' === step.action;
+ const builtIn = 'built-in' === item.kind;
+ const currencyWarning = builtIn && unsupportedCurrency( item, currencies );
+
+ return (
+
+
+
+
+ { status.label }
+
+
+
+ { item.name }
+
+ { CATEGORIES[ item.category ] }
+
+ { item.description }
+
+ { 'extension' === item.kind && ! soon && (
+
+ { sprintf(
+ /* translators: %s: extension name. */
+ __(
+ 'Uses the free %s extension.',
+ 'rave-payment-forms'
+ ),
+ item.extension.name
+ ) }
+
+ ) }
+
+ { currencyWarning && (
+
+ { sprintf(
+ /* translators: 1: currency code, 2: plugin name. */
+ __(
+ '%1$s, the currency set in %2$s, is not supported by Flutterwave.',
+ 'rave-payment-forms'
+ ),
+ item.currency,
+ item.host.name
+ ) }
+
+ ) }
+
+ { ! soon && (
+
+ ) }
+
+ );
+};
+
+const FILTERS = [
+ { key: 'all', label: __( 'All', 'rave-payment-forms' ) },
+].concat(
+ Object.entries( CATEGORIES ).map( ( [ key, label ] ) => ( { key, label } ) )
+);
+
+const Integrations = () => {
+ const [ data, setData ] = useState( null );
+ const tour = useTour( 'integrations', { ready: null !== data } );
+ const [ filter, setFilter ] = useState( 'all' );
+ const [ busy, setBusy ] = useState( '' );
+ const [ notice, setNotice ] = useState( null );
+
+ const load = useCallback(
+ () =>
+ fetchIntegrations()
+ .then( setData )
+ .catch( ( error ) =>
+ setNotice( {
+ status: 'error',
+ message:
+ error?.message ||
+ __(
+ 'Could not load integrations.',
+ 'rave-payment-forms'
+ ),
+ } )
+ ),
+ []
+ );
+
+ useEffect( () => {
+ load();
+ }, [ load ] );
+
+ const run = async ( item, task, success ) => {
+ setBusy( item.id );
+ try {
+ await task();
+ await load();
+ setNotice( { status: 'success', message: success } );
+ } catch ( error ) {
+ setNotice( {
+ status: 'error',
+ message:
+ error?.message ||
+ __(
+ 'Something went wrong. Please try again.',
+ 'rave-payment-forms'
+ ),
+ } );
+ } finally {
+ setBusy( '' );
+ }
+ };
+
+ const onAction = ( item, step ) =>
+ run(
+ item,
+ () =>
+ 'install' === step.action
+ ? installPlugin( step.target )
+ : activatePlugin( step.target ),
+ 'install' === step.action
+ ? __( 'Plugin installed and activated.', 'rave-payment-forms' )
+ : __( 'Plugin activated.', 'rave-payment-forms' )
+ );
+
+ const onToggle = ( item, enabled ) =>
+ run(
+ item,
+ () => setIntegration( item.id, enabled ),
+ enabled
+ ? sprintf(
+ /* translators: %s: plugin name. */
+ __(
+ 'Flutterwave is now a payment option in %s.',
+ 'rave-payment-forms'
+ ),
+ item.name
+ )
+ : sprintf(
+ /* translators: %s: plugin name. */
+ __(
+ 'Flutterwave is no longer offered in %s.',
+ 'rave-payment-forms'
+ ),
+ item.name
+ )
+ );
+
+ const items = useMemo( () => data?.items ?? [], [ data ] );
+ const shown =
+ 'all' === filter
+ ? items
+ : items.filter( ( item ) => item.category === filter );
+ const available = shown.filter( ( item ) => 'coming-soon' !== item.kind );
+ const soon = shown.filter( ( item ) => 'coming-soon' === item.kind );
+ const connected = items.filter( ( item ) => item.enabled ).length;
+ const caps = {
+ canInstall: !! data?.canInstall,
+ canActivate: !! data?.canActivate,
+ };
+
+ const card = ( item ) => (
+
+ );
+
+ return (
+ }
+ >
+
+ { notice && (
+ setNotice( null ) } />
+ ) }
+
+
+
+
+ { __( 'Integrations', 'rave-payment-forms' ) }
+
+
+ { __(
+ 'Take Flutterwave payments inside the plugins you already use to sell products, collect donations and run memberships.',
+ 'rave-payment-forms'
+ ) }
+
+
+ { data && (
+
+ { sprintf(
+ /* translators: %d: number of connected integrations. */
+ __( '%d connected', 'rave-payment-forms' ),
+ connected
+ ) }
+
+ ) }
+
+
+
+ { FILTERS.filter(
+ ( item ) =>
+ 'all' === item.key ||
+ items.some( ( entry ) => entry.category === item.key )
+ ).map( ( item ) => (
+ setFilter( item.key ) }
+ >
+ { item.label }
+
+ ) ) }
+
+
+ { null === data && ! notice && (
+
+ ) }
+
+ { data && available.length > 0 && (
+
+
+ { __( 'Available', 'rave-payment-forms' ) }
+
+
+ { available.map( card ) }
+
+
+ ) }
+
+ { data && soon.length > 0 && (
+
+
+ { __( 'Coming soon', 'rave-payment-forms' ) }
+
+
+ { soon.map( card ) }
+
+
+ ) }
+
+
+ { __(
+ 'Using another plugin to sell or take donations?',
+ 'rave-payment-forms'
+ ) }{ ' ' }
+
+ { __( 'Tell us which one', 'rave-payment-forms' ) }
+
+
+
+ );
+};
+
+export default Integrations;
diff --git a/client/admin/screens/PaymentForms.js b/client/admin/screens/PaymentForms.js
new file mode 100644
index 0000000..6ffadcb
--- /dev/null
+++ b/client/admin/screens/PaymentForms.js
@@ -0,0 +1,398 @@
+/**
+ * Payment Forms: every Flutterwave block and shortcode in use, with the pages
+ * they are on and the payments each page has taken, plus a builder for new forms.
+ */
+
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Page, Button, Toast } from '../components';
+import { HelpMenu, Tour, useTour } from '../components/tour';
+import { StatusPill } from '../components/list';
+import { ExternalIcon, PlusIcon, DocsIllustration } from '../icons';
+import { adminData, fetchForms } from '../lib/api';
+import { formatMoney } from '../lib/money';
+import { FORM_TYPES, typeLabel } from './form-types';
+import FormBuilder from './FormBuilder';
+
+const TABS = [
+ { key: 'all', label: __( 'All forms', 'rave-payment-forms' ) },
+ ...FORM_TYPES.map( ( type ) => ( { key: type.key, label: type.plural } ) ),
+];
+
+/**
+ * The page status shown in the Status column.
+ *
+ * @param {Object} page Page data.
+ * @return {string} Status key.
+ */
+const pageStatus = ( page ) =>
+ 'wp_block' === page.type ? 'synced' : page.status;
+
+/**
+ * "Customer enters", "NGN 5,000" or "—".
+ *
+ * @param {Object} item Form.
+ * @return {*} Amount cell content.
+ */
+const amountCell = ( item ) => {
+ if ( 'payment-methods' === item.type ) {
+ return — ;
+ }
+
+ if ( ! item.amount ) {
+ return (
+
+ { __( 'Customer enters', 'rave-payment-forms' ) }
+
+ );
+ }
+
+ return (
+
+ { formatMoney(
+ item.amount,
+ item.currency || adminData().siteCurrency,
+ { trimZeros: true }
+ ) }
+
+ );
+};
+
+/**
+ * "3 · NGN 15,000" or "—".
+ *
+ * @param {Object} payments Payment stats.
+ * @return {*} Payments cell content.
+ */
+const paymentsCell = ( payments ) => {
+ if ( ! payments.count ) {
+ return — ;
+ }
+
+ return (
+ <>
+
+ { sprintf(
+ /* translators: %d: number of successful payments. */
+ _n(
+ '%d payment',
+ '%d payments',
+ payments.count,
+ 'rave-payment-forms'
+ ),
+ payments.count
+ ) }
+
+
+ { payments.totals
+ .map( ( total ) =>
+ formatMoney( total.amount, total.currency, {
+ trimZeros: true,
+ } )
+ )
+ .join( ' · ' ) }
+
+ >
+ );
+};
+
+const PaymentForms = () => {
+ const [ data, setData ] = useState( null );
+ const tour = useTour( 'forms', { ready: null !== data } );
+ const [ tab, setTab ] = useState( 'all' );
+ const [ building, setBuilding ] = useState( false );
+ const [ notice, setNotice ] = useState( null );
+ const { canEditPages, transactions } = adminData();
+
+ useEffect( () => {
+ fetchForms()
+ .then( setData )
+ .catch( ( error ) =>
+ setNotice( {
+ status: 'error',
+ message:
+ error?.message ||
+ __(
+ 'Could not load your payment forms.',
+ 'rave-payment-forms'
+ ),
+ } )
+ );
+ }, [] );
+
+ const items = useMemo( () => data?.items ?? [], [ data ] );
+ const visible =
+ 'all' === tab ? items : items.filter( ( item ) => item.type === tab );
+ const counts = useMemo(
+ () =>
+ items.reduce(
+ ( acc, item ) => ( {
+ ...acc,
+ [ item.type ]: ( acc[ item.type ] || 0 ) + 1,
+ } ),
+ { all: items.length }
+ ),
+ [ items ]
+ );
+
+ const newForm = canEditPages && (
+ setBuilding( true ) }>
+ { __( 'New form', 'rave-payment-forms' ) }
+
+
+ );
+
+ return (
+ }
+ >
+
+ { notice && (
+ setNotice( null ) } />
+ ) }
+
+
+ { TABS.map( ( item ) => (
+ setTab( item.key ) }
+ >
+ { item.label }
+ { counts[ item.key ] ? (
+
+ { counts[ item.key ] }
+
+ ) : null }
+
+ ) ) }
+
+
+
+
+ { sprintf(
+ /* translators: %d: number of payment forms. */
+ _n(
+ '%d Payment form',
+ '%d Payment forms',
+ visible.length,
+ 'rave-payment-forms'
+ ),
+ visible.length
+ ) }
+
+
+
+
+ { null === data && ! notice && (
+
+ ) }
+
+ { data && 0 === items.length && (
+
+
+
+
+
+ { __(
+ 'Create your first payment form',
+ 'rave-payment-forms'
+ ) }
+
+
+ { __(
+ 'Payment buttons, forms, donation forms and pricing cards you add to your pages will be listed here with the payments they collect.',
+ 'rave-payment-forms'
+ ) }
+
+ { newForm }
+
+ ) }
+
+ { data && items.length > 0 && (
+
+
+
+
+
+ { __( 'Form', 'rave-payment-forms' ) }
+
+
+ { __( 'Amount', 'rave-payment-forms' ) }
+
+
+ { __( 'Used on', 'rave-payment-forms' ) }
+
+
+ { __( 'Status', 'rave-payment-forms' ) }
+
+
+ { __( 'Payments', 'rave-payment-forms' ) }
+
+
+
+ { __(
+ 'Actions',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+
+ { visible.map( ( item, row ) => {
+ const type = FORM_TYPES.find(
+ ( candidate ) => candidate.key === item.type
+ );
+ const Icon = type?.icon;
+
+ return (
+
+
+
+
+ { Icon }
+
+
+
+ { item.label ||
+ typeLabel(
+ item.type
+ ) }
+
+
+ { typeLabel(
+ item.type
+ ) }{ ' ' }
+ ·{ ' ' }
+ { 'block' === item.kind
+ ? __(
+ 'Block',
+ 'rave-payment-forms'
+ )
+ : __(
+ 'Shortcode',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+ { amountCell( item ) }
+
+
+ { item.page.title }
+
+
+ { sprintf(
+ /* translators: %s: date the page was last changed. */
+ __(
+ 'Updated %s',
+ 'rave-payment-forms'
+ ),
+ item.page.modified
+ ) }
+
+
+
+
+
+
+ { paymentsCell( item.payments ) }
+
+
+ { item.page.editUrl && (
+
+ { __(
+ 'Edit',
+ 'rave-payment-forms'
+ ) }
+
+ ) }
+ { item.page.viewUrl && (
+
+ { __(
+ 'View',
+ 'rave-payment-forms'
+ ) }
+
+
+ ) }
+
+
+ );
+ } ) }
+
+
+ { 0 === visible.length && (
+
+
+ { __(
+ 'No forms of this type yet.',
+ 'rave-payment-forms'
+ ) }
+
+
+ ) }
+
+ ) }
+
+
+ { __(
+ 'Payments are counted per page for payments made since this version of the plugin. Shortcodes and blocks both appear here.',
+ 'rave-payment-forms'
+ ) }
+
+
+ { building && (
+ setBuilding( false ) }
+ onNotice={ ( status, message ) =>
+ setNotice( { status, message } )
+ }
+ />
+ ) }
+
+ );
+};
+
+export default PaymentForms;
diff --git a/client/admin/screens/Settings.js b/client/admin/screens/Settings.js
new file mode 100644
index 0000000..f565018
--- /dev/null
+++ b/client/admin/screens/Settings.js
@@ -0,0 +1,177 @@
+/**
+ * Post-onboarding screen: the same groups of fields as the wizard, now as tabs.
+ *
+ * Saving from any tab persists that tab's fields only, matching the wizard so
+ * an unsaved edit on one tab can never overwrite another.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Page, Button } from '../components';
+import { HelpMenu, Tour, useTour } from '../components/tour';
+import { GeneralForm, ApiForm, MethodsForm, RedirectsForm } from './forms';
+import { STEP_FIELDS } from '../lib/constants';
+import { saveSettings } from '../lib/api';
+import {
+ validateGeneral,
+ validateApi,
+ validateMethods,
+ validateRedirects,
+ formatErrors,
+} from '../lib/validate';
+
+const SAVE_AND_CONTINUE = __( 'Save & continue', 'rave-payment-forms' );
+
+const TABS = [
+ {
+ key: 'general',
+ label: __( 'General', 'rave-payment-forms' ),
+ crumb: __( 'Flutterwave Payments', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.general,
+ validate: validateGeneral,
+ Form: GeneralForm,
+ cta: SAVE_AND_CONTINUE,
+ },
+ {
+ key: 'api',
+ label: __( 'API & Webhook', 'rave-payment-forms' ),
+ crumb: __( 'General', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.api,
+ validate: validateApi,
+ Form: ApiForm,
+ cta: SAVE_AND_CONTINUE,
+ },
+ {
+ key: 'methods',
+ label: __( 'Payment Methods', 'rave-payment-forms' ),
+ crumb: __( 'API & Webhook', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.methods,
+ validate: validateMethods,
+ Form: MethodsForm,
+ cta: SAVE_AND_CONTINUE,
+ },
+ {
+ key: 'redirects',
+ label: __( 'Redirects', 'rave-payment-forms' ),
+ crumb: __( 'Payment Methods', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.redirects,
+ validate: validateRedirects,
+ Form: RedirectsForm,
+ cta: __( 'Save', 'rave-payment-forms' ),
+ },
+];
+
+/**
+ * The tab named by `?tab=` in the URL, falling back to the first.
+ *
+ * @return {number} Tab index.
+ */
+const initialTab = () => {
+ const key = new URLSearchParams( window.location.search ).get( 'tab' );
+ const index = TABS.findIndex( ( item ) => item.key === key );
+
+ return index > -1 ? index : 0;
+};
+
+/**
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Function} props.onSaved Receives the payload returned by the API.
+ * @param {Function} props.onNotice `( status, message ) => void`.
+ * @return {Element} The settings screen.
+ */
+const Settings = ( { values, setValue, onSaved, onNotice } ) => {
+ const [ active, setActive ] = useState( initialTab );
+ // This screen only shows once onboarding is done, including straight after the wizard.
+ const tour = useTour( 'settings', { onboarded: true } );
+ const [ busy, setBusy ] = useState( false );
+ const [ attempted, setAttempted ] = useState( false );
+
+ const tab = TABS[ active ];
+ const { Form } = tab;
+ const found = tab.validate( values );
+ const errors = attempted ? found : formatErrors( found, values );
+
+ const goTo = ( next ) => {
+ setAttempted( false );
+ setActive( next );
+ };
+
+ const save = async () => {
+ if ( Object.keys( found ).length ) {
+ setAttempted( true );
+ return;
+ }
+
+ setBusy( true );
+
+ try {
+ const patch = tab.fields.reduce(
+ ( acc, key ) => ( { ...acc, [ key ]: values[ key ] } ),
+ {}
+ );
+ const payload = await saveSettings( patch );
+ onSaved( payload );
+ onNotice( 'success', __( 'Changes saved.', 'rave-payment-forms' ) );
+
+ if ( active < TABS.length - 1 ) {
+ goTo( active + 1 );
+ }
+ } catch ( error ) {
+ onNotice(
+ 'error',
+ error?.message ||
+ __(
+ 'Could not save your settings. Please try again.',
+ 'rave-payment-forms'
+ )
+ );
+ } finally {
+ setBusy( false );
+ }
+ };
+
+ return (
+ 0 ? () => goTo( active - 1 ) : undefined }
+ actions={ }
+ >
+
+
+ { TABS.map( ( item, index ) => (
+ {
+ goTo( index );
+ } }
+ >
+ { item.label }
+
+ ) ) }
+
+
+
+
+
+
+
+ { tab.cta }
+
+
+
+
+ );
+};
+
+export default Settings;
diff --git a/client/admin/screens/Success.js b/client/admin/screens/Success.js
new file mode 100644
index 0000000..d36f7ca
--- /dev/null
+++ b/client/admin/screens/Success.js
@@ -0,0 +1,58 @@
+/**
+ * Terminal screen of the wizard: confirms setup and hands the merchant the
+ * shortcode they need next, since a form only appears once it is on a page.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { SuccessIllustration } from '../icons';
+import { Button, CopyField } from '../components';
+import { adminData } from '../lib/api';
+
+const SHORTCODE = '[flw-pay-form amount="5000"]';
+
+/**
+ * @param {Object} props
+ * @param {Function} props.onDone Opens the settings screen.
+ * @return {Element} The success screen.
+ */
+const Success = ( { onDone } ) => {
+ const { newPageUrl } = adminData();
+
+ return (
+
+
+
{ __( 'Setup successful!', 'rave-payment-forms' ) }
+
+ { __(
+ 'Your Flutterwave account is connected. Add a payment form to any page with this shortcode:',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+
+ { __(
+ 'Leave out amount to let customers enter their own, or use [flw-donation-form] for donations.',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+ { newPageUrl && (
+ window.location.assign( newPageUrl ) }
+ >
+ { __( 'Create a page', 'rave-payment-forms' ) }
+
+ ) }
+
+ { __( 'Go to settings', 'rave-payment-forms' ) }
+
+
+
+ );
+};
+
+export default Success;
diff --git a/client/admin/screens/Transactions.js b/client/admin/screens/Transactions.js
new file mode 100644
index 0000000..649d0e3
--- /dev/null
+++ b/client/admin/screens/Transactions.js
@@ -0,0 +1,597 @@
+/**
+ * Transactions: payments made through the forms, with date, status and
+ * currency filters, a CSV download and a details drawer.
+ */
+
+import { useCallback, useEffect, useState } from '@wordpress/element';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Page, Button, Toast } from '../components';
+import { HelpMenu, Tour, useTour } from '../components/tour';
+import {
+ DatePresets,
+ DateRange,
+ DetailRow,
+ Drawer,
+ MultiSelect,
+ Pagination,
+ Popover,
+ StatusPill,
+ ToolbarButton,
+} from '../components/list';
+import { DownloadIcon, FilterIcon, InfoIcon } from '../icons';
+import {
+ adminData,
+ deletePayment,
+ fetchPayments,
+ toQuery,
+ verifyPayment,
+} from '../lib/api';
+import { clampRange, presetRange } from '../lib/dates';
+import { formatMoney } from '../lib/money';
+
+const PER_PAGE = 20;
+
+const STATUS_OPTIONS = [
+ { value: 'successful', label: __( 'Successful', 'rave-payment-forms' ) },
+ { value: 'pending', label: __( 'Pending', 'rave-payment-forms' ) },
+ { value: 'failed', label: __( 'Failed', 'rave-payment-forms' ) },
+ { value: 'cancelled', label: __( 'Cancelled', 'rave-payment-forms' ) },
+ { value: 'review', label: __( 'Needs review', 'rave-payment-forms' ) },
+];
+
+const defaultFilters = () => ( {
+ preset: '1y',
+ ...presetRange( '1y' ),
+ status: [],
+ currency: [],
+} );
+
+/**
+ * How many filter groups are in effect (dates always count).
+ *
+ * @param {Object} filters Filters.
+ * @return {number} Count.
+ */
+const appliedCount = ( filters ) =>
+ ( filters.from || filters.to ? 1 : 0 ) +
+ ( filters.status.length ? 1 : 0 ) +
+ ( filters.currency.length ? 1 : 0 );
+
+/**
+ * The filter popover. Edits a draft and applies it on "Apply filter".
+ *
+ * @param {Object} props
+ * @param {Object} props.filters Applied filters.
+ * @param {string[]} props.currencies Currencies to offer.
+ * @param {Function} props.onApply Receives the new filters.
+ * @return {Element} The filter control.
+ */
+const FilterMenu = ( { filters, currencies, onApply } ) => {
+ const [ open, setOpen ] = useState( false );
+ const [ draft, setDraft ] = useState( filters );
+
+ useEffect( () => {
+ if ( open ) {
+ setDraft( filters );
+ }
+ }, [ open, filters ] );
+
+ const close = useCallback( () => setOpen( false ), [] );
+
+ return (
+ }
+ aria-expanded={ open }
+ onClick={ () => setOpen( ! open ) }
+ >
+ { sprintf(
+ /* translators: %d: number of filters in effect. */
+ __( 'Filter applied: %d', 'rave-payment-forms' ),
+ appliedCount( filters )
+ ) }
+
+ }
+ >
+
+ setDraft( { ...draft, preset, ...presetRange( preset ) } )
+ }
+ />
+
+ { __( 'Custom date range', 'rave-payment-forms' ) }
+
+ setDraft( { ...draft, preset: '', ...range } )
+ }
+ />
+
+
+ { __(
+ 'Date range cannot exceed the last 1 year',
+ 'rave-payment-forms'
+ ) }
+
+
+
+ setDraft( { ...draft, status } ) }
+ />
+
+
+ ( {
+ value: code,
+ label: code,
+ } ) ) }
+ value={ draft.currency }
+ onChange={ ( currency ) =>
+ setDraft( { ...draft, currency } )
+ }
+ />
+
+
+ setDraft( defaultFilters() ) }
+ >
+ { __( 'Clear', 'rave-payment-forms' ) }
+
+ {
+ onApply( {
+ ...draft,
+ ...clampRange( draft.from, draft.to ),
+ } );
+ close();
+ } }
+ >
+ { __( 'Apply filter', 'rave-payment-forms' ) }
+
+
+
+ );
+};
+
+/**
+ * The Custom Download popover: pick a range and download a CSV.
+ *
+ * @return {Element} The download control.
+ */
+const DownloadMenu = () => {
+ const [ open, setOpen ] = useState( false );
+ const [ range, setRange ] = useState( {
+ preset: '30d',
+ ...presetRange( '30d' ),
+ } );
+ const close = useCallback( () => setOpen( false ), [] );
+
+ const download = () => {
+ const { from, to } = clampRange( range.from, range.to );
+ window.location.assign(
+ `${ adminData().exportUrl }&${ toQuery( { from, to } ) }`
+ );
+ close();
+ };
+
+ return (
+ }
+ aria-expanded={ open }
+ onClick={ () => setOpen( ! open ) }
+ >
+ { __( 'Custom Download', 'rave-payment-forms' ) }
+
+ }
+ >
+
+ setRange( { preset, ...presetRange( preset ) } )
+ }
+ />
+
+ { __( 'Custom date range', 'rave-payment-forms' ) }
+ setRange( { preset: '', ...next } ) }
+ />
+
+
+ { __(
+ 'Downloads a CSV file you can open in any spreadsheet.',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+ { __( 'Cancel', 'rave-payment-forms' ) }
+
+
+ { __( 'Download', 'rave-payment-forms' ) }
+
+
+
+ );
+};
+
+/**
+ * Details for one transaction, with re-verify and delete.
+ *
+ * @param {Object} props
+ * @param {Object} props.item Transaction.
+ * @param {Function} props.onClose Close handler.
+ * @param {Function} props.onChanged Receives the updated item, or null when deleted.
+ * @param {Function} props.onNotice `( status, message ) => void`.
+ * @return {Element} The drawer.
+ */
+const TransactionDrawer = ( { item, onClose, onChanged, onNotice } ) => {
+ const [ busy, setBusy ] = useState( '' );
+
+ const run = async ( action ) => {
+ setBusy( action );
+ try {
+ if ( 'verify' === action ) {
+ const updated = await verifyPayment( item.id );
+ onChanged( updated );
+ onNotice(
+ 'success',
+ __(
+ 'Status updated from Flutterwave.',
+ 'rave-payment-forms'
+ )
+ );
+ } else {
+ await deletePayment( item.id );
+ onChanged( null );
+ onNotice(
+ 'success',
+ __( 'Transaction deleted.', 'rave-payment-forms' )
+ );
+ }
+ } catch ( error ) {
+ onNotice(
+ 'error',
+ error?.message ||
+ __(
+ 'Something went wrong. Please try again.',
+ 'rave-payment-forms'
+ )
+ );
+ } finally {
+ setBusy( '' );
+ }
+ };
+
+ return (
+
+ { 'successful' !== item.statusGroup && (
+ run( 'verify' ) }
+ >
+ { __(
+ 'Re-verify with Flutterwave',
+ 'rave-payment-forms'
+ ) }
+
+ ) }
+ {
+ if (
+ // A native confirm is the lightest guard for a one-off destructive action.
+ // eslint-disable-next-line no-alert
+ window.confirm(
+ __(
+ 'Delete this transaction record? This cannot be undone.',
+ 'rave-payment-forms'
+ )
+ )
+ ) {
+ run( 'delete' );
+ }
+ } }
+ >
+ { __( 'Delete record', 'rave-payment-forms' ) }
+
+ >
+ }
+ >
+
+ { formatMoney( item.amount, item.currency ) }
+
+
+
+
+ { item.reference }
+
+
+ { item.customerName }
+
+
+ { item.customerEmail }
+
+
+ { `${ item.dateDisplay } ${ item.timeDisplay }` }
+
+
+ { item.status }
+
+
+ { item.flutterwaveId }
+
+
+ { item.source && (
+
+ { item.source.title }
+
+ ) }
+
+
+
+ );
+};
+
+const Transactions = () => {
+ const [ filters, setFilters ] = useState( defaultFilters );
+ const [ page, setPage ] = useState( 1 );
+ const [ data, setData ] = useState( null );
+ const tour = useTour( 'transactions', { ready: null !== data } );
+ const [ loading, setLoading ] = useState( true );
+ const [ selected, setSelected ] = useState( null );
+ const [ notice, setNotice ] = useState( null );
+ const [ reload, setReload ] = useState( 0 );
+
+ useEffect( () => {
+ let cancelled = false;
+ setLoading( true );
+
+ fetchPayments( {
+ page,
+ per_page: PER_PAGE,
+ from: filters.from,
+ to: filters.to,
+ status: filters.status,
+ currency: filters.currency,
+ } )
+ .then( ( payload ) => ! cancelled && setData( payload ) )
+ .catch(
+ ( error ) =>
+ ! cancelled &&
+ setNotice( {
+ status: 'error',
+ message:
+ error?.message ||
+ __(
+ 'Could not load transactions.',
+ 'rave-payment-forms'
+ ),
+ } )
+ )
+ .finally( () => ! cancelled && setLoading( false ) );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ filters, page, reload ] );
+
+ useEffect( () => {
+ if ( ! notice ) {
+ return undefined;
+ }
+ const timer = setTimeout( () => setNotice( null ), 4000 );
+ return () => clearTimeout( timer );
+ }, [ notice ] );
+
+ const total = data?.total ?? 0;
+ const items = data?.items ?? [];
+
+ return (
+ }
+ >
+
+ { notice && (
+ setNotice( null ) } />
+ ) }
+
+
+
+ { sprintf(
+ /* translators: %d: number of transactions. */
+ _n(
+ '%d Transaction',
+ '%d Transactions',
+ total,
+ 'rave-payment-forms'
+ ),
+ total
+ ) }
+
+
+ {
+ setPage( 1 );
+ setFilters( next );
+ } }
+ />
+
+
+
+
+
+
+
+
+
+ { __( 'Customer', 'rave-payment-forms' ) }
+
+
+ { __( 'Amount', 'rave-payment-forms' ) }
+
+
+ { __( 'Date', 'rave-payment-forms' ) }
+
+
+ { __( 'Status', 'rave-payment-forms' ) }
+
+
+
+
+ { items.map( ( item, row ) => (
+ setSelected( item ) }
+ onKeyDown={ ( event ) =>
+ 'Enter' === event.key && setSelected( item )
+ }
+ >
+
+
+ { item.customerName ||
+ item.customerEmail ||
+ __(
+ 'Unknown customer',
+ 'rave-payment-forms'
+ ) }
+
+ { item.customerName &&
+ item.customerEmail && (
+
+ { item.customerEmail }
+
+ ) }
+
+
+
+ { formatMoney(
+ item.amount,
+ item.currency
+ ) }
+
+
+
+ { item.dateDisplay }{ ' ' }
+
+ { item.timeDisplay }
+
+
+
+
+
+
+ ) ) }
+
+
+
+ { ! loading && 0 === items.length && (
+
+
+ { __(
+ 'No transactions found',
+ 'rave-payment-forms'
+ ) }
+
+
+ { appliedCount( filters ) > 1
+ ? __(
+ 'No payments match these filters.',
+ 'rave-payment-forms'
+ )
+ : __(
+ 'Payments made through your forms in this period will show up here.',
+ 'rave-payment-forms'
+ ) }
+
+ { appliedCount( filters ) > 1 && (
+
setFilters( defaultFilters() ) }
+ >
+ { __( 'Clear filters', 'rave-payment-forms' ) }
+
+ ) }
+
+ ) }
+
+
+
+
+ { selected && (
+ setSelected( null ) }
+ onNotice={ ( status, message ) =>
+ setNotice( { status, message } )
+ }
+ onChanged={ ( updated ) => {
+ setSelected( updated );
+ setReload( ( value ) => value + 1 );
+ } }
+ />
+ ) }
+
+ );
+};
+
+export default Transactions;
diff --git a/client/admin/screens/Welcome.js b/client/admin/screens/Welcome.js
new file mode 100644
index 0000000..5b4c0fe
--- /dev/null
+++ b/client/admin/screens/Welcome.js
@@ -0,0 +1,96 @@
+/**
+ * First-run screen. Shown until the merchant either finishes the wizard or is
+ * detected as already configured.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { Page, Button } from '../components';
+import {
+ FlutterwaveWordmark,
+ DocsIllustration,
+ SupportIllustration,
+} from '../icons';
+import { adminData } from '../lib/api';
+
+/**
+ * @param {Object} props
+ * @param {Function} props.onActivate Starts the wizard.
+ * @return {Element} The welcome screen.
+ */
+const Welcome = ( { onActivate } ) => {
+ const { documentation, support } = adminData();
+
+ return (
+
+
+
+
+
+ { __( 'Welcome to Flutterwave', 'rave-payment-forms' ) }
+
+ { __( 'for Business', 'rave-payment-forms' ) }
+
+
+
+ { __(
+ 'Add payment and donation forms to any page and collect payments in over 30 currencies worldwide, with Cards, Mobile money, Bank transfer, Apple Pay, Google Pay and more.',
+ 'rave-payment-forms'
+ ) }
+
+
+
+ { __( 'Activate Flutterwave', 'rave-payment-forms' ) }
+
+
+
+
+
+ );
+};
+
+export default Welcome;
diff --git a/client/admin/screens/Wizard.js b/client/admin/screens/Wizard.js
new file mode 100644
index 0000000..a26b003
--- /dev/null
+++ b/client/admin/screens/Wizard.js
@@ -0,0 +1,183 @@
+/**
+ * The four-step onboarding wizard.
+ *
+ * Each step saves on "Save & continue" so a merchant who drops out halfway
+ * keeps what they entered, and the final step flips the onboarding flag.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Page, StepBadge, Stepper, Button, LoadingScreen } from '../components';
+import { GeneralForm, ApiForm, MethodsForm, RedirectsForm } from './forms';
+import { STEPS, STEP_FIELDS } from '../lib/constants';
+import { saveSettings, completeOnboarding } from '../lib/api';
+import {
+ validateGeneral,
+ validateApi,
+ validateMethods,
+ validateRedirects,
+ formatErrors,
+} from '../lib/validate';
+
+const SUBTITLE = __(
+ 'Please fill in your correct details to set up your payment forms',
+ 'rave-payment-forms'
+);
+
+const STEP_META = [
+ {
+ title: __( 'General details', 'rave-payment-forms' ),
+ subtitle: SUBTITLE,
+ crumb: __( 'Flutterwave Payments', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.general,
+ validate: validateGeneral,
+ Form: GeneralForm,
+ },
+ {
+ title: __( 'API & webhook', 'rave-payment-forms' ),
+ subtitle: __(
+ 'Find your keys under Settings → API keys in your Flutterwave dashboard.',
+ 'rave-payment-forms'
+ ),
+ crumb: __( 'General', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.api,
+ validate: validateApi,
+ Form: ApiForm,
+ },
+ {
+ title: __( 'Payment methods', 'rave-payment-forms' ),
+ subtitle: __(
+ 'Choose how customers can pay through your forms.',
+ 'rave-payment-forms'
+ ),
+ crumb: __( 'API & Webhook', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.methods,
+ validate: validateMethods,
+ Form: MethodsForm,
+ },
+ {
+ title: __( 'Redirects', 'rave-payment-forms' ),
+ subtitle: __(
+ 'Where customers land after paying. Your home page works to start with.',
+ 'rave-payment-forms'
+ ),
+ crumb: __( 'Payment methods', 'rave-payment-forms' ),
+ fields: STEP_FIELDS.redirects,
+ validate: validateRedirects,
+ Form: RedirectsForm,
+ },
+];
+
+/**
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Function} props.onSaved Receives the payload returned by the API.
+ * @param {Function} props.onFinished Called after the final step saves.
+ * @param {Function} props.onError Receives an error message.
+ * @return {Element} The wizard.
+ */
+const Wizard = ( { values, setValue, onSaved, onFinished, onError } ) => {
+ const [ index, setIndex ] = useState( 0 );
+ const [ busy, setBusy ] = useState( false );
+ const [ submitting, setSubmitting ] = useState( false );
+ const [ attempted, setAttempted ] = useState( false );
+
+ const meta = STEP_META[ index ];
+ const { Form } = meta;
+ const isLast = index === STEP_META.length - 1;
+ const found = meta.validate( values );
+
+ // Mirrors the designs: the primary button stays faded until the step is
+ // actually completable. Format problems show straight away so a faded
+ // button always has a visible reason; "required" errors wait for a click.
+ const complete = Object.keys( found ).length === 0;
+ const errors = attempted ? found : formatErrors( found, values );
+
+ const goTo = ( next ) => {
+ setAttempted( false );
+ setIndex( next );
+ };
+
+ const patchFor = ( step ) =>
+ step.fields.reduce(
+ ( patch, key ) => ( { ...patch, [ key ]: values[ key ] } ),
+ {}
+ );
+
+ const advance = async () => {
+ if ( ! complete ) {
+ setAttempted( true );
+ return;
+ }
+
+ setBusy( true );
+
+ try {
+ if ( isLast ) {
+ setSubmitting( true );
+ const payload = await completeOnboarding( patchFor( meta ) );
+ onSaved( payload );
+ onFinished();
+ } else {
+ const payload = await saveSettings( patchFor( meta ) );
+ onSaved( payload );
+ goTo( index + 1 );
+ }
+ } catch ( error ) {
+ setSubmitting( false );
+ onError(
+ error?.message ||
+ __(
+ 'Could not save your settings. Please try again.',
+ 'rave-payment-forms'
+ )
+ );
+ } finally {
+ setBusy( false );
+ }
+ };
+
+ if ( submitting ) {
+ return ;
+ }
+
+ return (
+ 0 ? () => goTo( index - 1 ) : undefined }
+ aside={ }
+ >
+
+
+ { meta.title }
+ { meta.subtitle }
+
+
+
+
+ 0 ? 'has-back' : '' }` }>
+ { index > 0 && (
+ goTo( index - 1 ) }
+ disabled={ busy }
+ >
+ { __( 'Go back', 'rave-payment-forms' ) }
+
+ ) }
+
+ { isLast
+ ? __( 'Save', 'rave-payment-forms' )
+ : __( 'Save & continue', 'rave-payment-forms' ) }
+
+
+
+ );
+};
+
+export default Wizard;
diff --git a/client/admin/screens/form-types.js b/client/admin/screens/form-types.js
new file mode 100644
index 0000000..96a8035
--- /dev/null
+++ b/client/admin/screens/form-types.js
@@ -0,0 +1,73 @@
+/**
+ * The form types shared by the Payment Forms screen and the builder.
+ */
+
+import { __ } from '@wordpress/i18n';
+import {
+ buttonIcon,
+ formIcon,
+ donationIcon,
+ pricingIcon,
+ methodsIcon,
+} from '../../blocks/icons';
+
+export const FORM_TYPES = [
+ {
+ key: 'payment-button',
+ label: __( 'Payment button', 'rave-payment-forms' ),
+ plural: __( 'Buttons', 'rave-payment-forms' ),
+ description: __(
+ 'A fixed price with a one-line checkout.',
+ 'rave-payment-forms'
+ ),
+ icon: buttonIcon,
+ buildable: true,
+ },
+ {
+ key: 'payment-form',
+ label: __( 'Payment form', 'rave-payment-forms' ),
+ plural: __( 'Forms', 'rave-payment-forms' ),
+ description: __( 'Invoices and custom amounts.', 'rave-payment-forms' ),
+ icon: formIcon,
+ buildable: true,
+ },
+ {
+ key: 'donation-form',
+ label: __( 'Donation form', 'rave-payment-forms' ),
+ plural: __( 'Donations', 'rave-payment-forms' ),
+ description: __( 'One-off or recurring gifts.', 'rave-payment-forms' ),
+ icon: donationIcon,
+ buildable: true,
+ },
+ {
+ key: 'pricing-card',
+ label: __( 'Pricing card', 'rave-payment-forms' ),
+ plural: __( 'Pricing', 'rave-payment-forms' ),
+ description: __(
+ 'A plan or product with its features.',
+ 'rave-payment-forms'
+ ),
+ icon: pricingIcon,
+ buildable: true,
+ },
+ {
+ key: 'payment-methods',
+ label: __( 'Payment methods badge', 'rave-payment-forms' ),
+ plural: __( 'Badges', 'rave-payment-forms' ),
+ description: __(
+ 'Shows the payment methods you accept.',
+ 'rave-payment-forms'
+ ),
+ icon: methodsIcon,
+ buildable: false,
+ },
+];
+
+/**
+ * The human name of a form type.
+ *
+ * @param {string} key Type key.
+ * @return {string} Label.
+ */
+export const typeLabel = ( key ) =>
+ FORM_TYPES.find( ( type ) => type.key === key )?.label || key;
diff --git a/client/admin/screens/forms.js b/client/admin/screens/forms.js
new file mode 100644
index 0000000..a154058
--- /dev/null
+++ b/client/admin/screens/forms.js
@@ -0,0 +1,508 @@
+/**
+ * The form bodies, shared by the onboarding wizard and the tabbed settings
+ * screen so both surfaces stay in step with one another.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import {
+ Field,
+ TextInput,
+ TextArea,
+ Select,
+ Checkbox,
+ CopyField,
+ SecretInput,
+} from '../components';
+import { METHOD_MARKS } from '../icons';
+import {
+ CURRENCY_OPTIONS,
+ COUNTRY_OPTIONS,
+ PAYMENT_METHODS,
+ ADVANCED_METHODS,
+ TOOLTIPS,
+} from '../lib/constants';
+import { keyMode } from '../lib/validate';
+
+/**
+ * A random 64-character hex string, the same shape the server generates.
+ *
+ * @return {string} The new secret hash.
+ */
+const generateSecretHash = () => {
+ const bytes = new Uint8Array( 32 );
+ window.crypto.getRandomValues( bytes );
+
+ return Array.from( bytes, ( byte ) =>
+ byte.toString( 16 ).padStart( 2, '0' )
+ ).join( '' );
+};
+
+/**
+ * Step 1: how the forms and the Flutterwave payment page present the business.
+ *
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Object} [props.errors] Per-field validation messages.
+ * @return {Element} The form body.
+ */
+export const GeneralForm = ( { values, setValue, errors = {} } ) => (
+ <>
+
+
+ setValue( 'title', event.target.value )
+ }
+ />
+
+
+
+
+
+
+
+ setValue( 'logoUrl', event.target.value )
+ }
+ />
+
+
+
+
+
+ setValue( 'currency', event.target.value )
+ }
+ />
+
+
+
+
+ setValue( 'country', event.target.value )
+ }
+ />
+
+
+
+
+
+ setValue( 'buttonText', event.target.value )
+ }
+ />
+
+
+
+ { __( 'Donation form', 'rave-payment-forms' ) }
+
+
+ { __(
+ 'Optional. Shown above the [flw-donation-form] shortcode.',
+ 'rave-payment-forms'
+ ) }
+
+
+
+
+ setValue( 'donationTitle', event.target.value )
+ }
+ />
+
+
+
+
+ >
+);
+
+/**
+ * Step 2: API keys, plus the webhook URL and secret hash to copy into the
+ * Flutterwave dashboard.
+ *
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Object} [props.errors] Per-field validation messages.
+ * @return {Element} The form body.
+ */
+export const ApiForm = ( { values, setValue, errors = {} } ) => {
+ const mode = keyMode( values.publicKey, 'FLWPUBK' );
+
+ return (
+ <>
+ { mode && (
+
+ { 'live' === mode
+ ? __(
+ 'Live keys: customers will be charged real money.',
+ 'rave-payment-forms'
+ )
+ : __(
+ 'Test keys: payments are simulated, no real money moves.',
+ 'rave-payment-forms'
+ ) }
+
+ ) }
+
+
+
+ setValue( 'publicKey', event.target.value.trim() )
+ }
+ />
+
+
+
+
+ setValue( 'secretKey', event.target.value.trim() )
+ }
+ />
+
+
+
+
+
+
+
+
+
+ setValue( 'secretHash', event.target.value )
+ }
+ />
+
+ setValue( 'secretHash', generateSecretHash() )
+ }
+ >
+ { __( 'Generate', 'rave-payment-forms' ) }
+
+
+
+ >
+ );
+};
+
+/**
+ * Step 3: the payment methods offered at checkout, plus the advanced options
+ * existing merchants may already rely on.
+ *
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Object} [props.errors] Per-field validation messages.
+ * @return {Element} The form body.
+ */
+export const MethodsForm = ( { values, setValue, errors = {} } ) => {
+ const [ advancedOpen, setAdvancedOpen ] = useState( false );
+
+ const toggle = ( listKey, key, checked ) => {
+ const current = values[ listKey ] || [];
+ setValue(
+ listKey,
+ checked
+ ? [ ...new Set( [ ...current, key ] ) ]
+ : current.filter( ( item ) => item !== key )
+ );
+ };
+
+ return (
+ <>
+
+
+ { errors.paymentMethods && (
+ { errors.paymentMethods }
+ ) }
+
+
+
setAdvancedOpen( ! advancedOpen ) }
+ >
+ { __( 'Advanced', 'rave-payment-forms' ) }
+
+
+
+ { advancedOpen && (
+
+
+ { __(
+ 'Additional Flutterwave payment options. These are not offered unless selected.',
+ 'rave-payment-forms'
+ ) }
+
+
+
+ { ADVANCED_METHODS.map( ( method ) => (
+
+ toggle(
+ 'advancedMethods',
+ method.key,
+ checked
+ )
+ }
+ label={ method.label }
+ />
+ ) ) }
+
+
+
+ setValue( 'useThemeStyle', checked )
+ }
+ label={ __(
+ "Style payment forms with my theme instead of Flutterwave's design",
+ 'rave-payment-forms'
+ ) }
+ />
+
+ ) }
+
+ >
+ );
+};
+
+/**
+ * Step 4: where customers land after paying.
+ *
+ * @param {Object} props
+ * @param {Object} props.values Current form values.
+ * @param {Function} props.setValue `( key, value ) => void`.
+ * @param {Object} [props.errors] Per-field validation messages.
+ * @return {Element} The form body.
+ */
+export const RedirectsForm = ( { values, setValue, errors = {} } ) => {
+ const fields = [
+ {
+ key: 'successUrl',
+ label: __( 'After a successful payment', 'rave-payment-forms' ),
+ },
+ {
+ key: 'failedUrl',
+ label: __( 'After a failed payment', 'rave-payment-forms' ),
+ },
+ {
+ key: 'pendingUrl',
+ label: __(
+ 'While a payment is still processing',
+ 'rave-payment-forms'
+ ),
+ },
+ ];
+
+ return fields.map( ( field ) => (
+
+
+ setValue( field.key, event.target.value )
+ }
+ />
+
+ ) );
+};
diff --git a/client/blocks/components.js b/client/blocks/components.js
new file mode 100644
index 0000000..727560d
--- /dev/null
+++ b/client/blocks/components.js
@@ -0,0 +1,181 @@
+/**
+ * Controls shared by the Flutterwave blocks.
+ */
+
+import { __ } from '@wordpress/i18n';
+import {
+ useBlockProps,
+ InspectorControls,
+ PanelColorSettings,
+} from '@wordpress/block-editor';
+import {
+ Disabled,
+ Notice,
+ PanelBody,
+ RangeControl,
+ SelectControl,
+ Spinner,
+ TextControl,
+} from '@wordpress/components';
+import ServerSideRender from '@wordpress/server-side-render';
+import { parseAmount } from './lib/format';
+
+/**
+ * Values passed in from PHP (FLW_Blocks::editor_data()).
+ *
+ * @return {Object} Editor data.
+ */
+export const editorData = () => window.flwBlocksData || {};
+
+/**
+ * The server-rendered block, made non-interactive inside the editor.
+ *
+ * @param {Object} props
+ * @param {string} props.name Block name.
+ * @param {Object} props.attributes Block attributes.
+ * @return {Element} The preview.
+ */
+export const Preview = ( { name, attributes } ) => (
+
+
+ (
+
+
+
+ ) }
+ />
+
+
+);
+
+/**
+ * Sidebar notice while the plugin is not connected to Flutterwave.
+ *
+ * @return {Element|null} The notice.
+ */
+export const SetupNotice = () => {
+ const { configured, settingsUrl } = editorData();
+
+ if ( configured ) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+/**
+ * Amount input. 0 is shown empty.
+ *
+ * @param {Object} props
+ * @param {string} props.label Label.
+ * @param {number} props.value Current amount.
+ * @param {Function} props.onChange Receives the parsed amount.
+ * @param {string} [props.help] Help text.
+ * @return {Element} The control.
+ */
+export const AmountControl = ( { label, value, onChange, help } ) => (
+ 0 ? String( value ) : '' }
+ onChange={ ( next ) => onChange( parseAmount( next ) ) }
+ />
+);
+
+/**
+ * Currency picker.
+ *
+ * @param {Object} props
+ * @param {string} props.value Current currency, or '' for the default.
+ * @param {Function} props.onChange Receives the new value.
+ * @param {string} props.defaultLabel Label for the empty option.
+ * @return {Element} The control.
+ */
+export const CurrencyControl = ( { value, onChange, defaultLabel } ) => {
+ const { currencies = [] } = editorData();
+
+ return (
+ ( {
+ value: code,
+ label: code,
+ } ) ),
+ ] }
+ onChange={ onChange }
+ />
+ );
+};
+
+/**
+ * Colour and corner radius controls, in the block's Styles tab.
+ *
+ * @param {Object} props
+ * @param {Object} props.attributes Block attributes.
+ * @param {Function} props.setAttributes Attribute setter.
+ * @return {Element} The panels.
+ */
+export const StyleControls = ( { attributes, setAttributes } ) => (
+
+
+ setAttributes( { accentColor: color || '' } ),
+ label: __( 'Button & accent', 'rave-payment-forms' ),
+ },
+ {
+ value: attributes.accentTextColor || undefined,
+ onChange: ( color ) =>
+ setAttributes( { accentTextColor: color || '' } ),
+ label: __( 'Button text', 'rave-payment-forms' ),
+ },
+ ] }
+ />
+
+
+ setAttributes( { borderRadius } )
+ }
+ />
+
+
+);
diff --git a/client/blocks/edit/donation-form.js b/client/blocks/edit/donation-form.js
new file mode 100644
index 0000000..83d556f
--- /dev/null
+++ b/client/blocks/edit/donation-form.js
@@ -0,0 +1,99 @@
+/**
+ * Editor for flutterwave/donation-form.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ PanelBody,
+ TextControl,
+ TextareaControl,
+ ToggleControl,
+} from '@wordpress/components';
+import {
+ CurrencyControl,
+ Preview,
+ SetupNotice,
+ StyleControls,
+} from '../components';
+import { parsePresetAmounts } from '../lib/format';
+
+const Edit = ( { name, attributes, setAttributes } ) => {
+ const presets = parsePresetAmounts( attributes.amounts );
+
+ return (
+ <>
+
+
+
+ setAttributes( { heading } ) }
+ />
+ setAttributes( { message } ) }
+ />
+
+
+ setAttributes( { amounts } ) }
+ />
+
+ setAttributes( { currency } )
+ }
+ />
+
+ setAttributes( { showFrequency } )
+ }
+ />
+
+
+
+
+ >
+ );
+};
+
+export default Edit;
diff --git a/client/blocks/edit/payment-button.js b/client/blocks/edit/payment-button.js
new file mode 100644
index 0000000..9464871
--- /dev/null
+++ b/client/blocks/edit/payment-button.js
@@ -0,0 +1,121 @@
+/**
+ * Editor for flutterwave/payment-button.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ PanelBody,
+ SelectControl,
+ TextControl,
+ ToggleControl,
+} from '@wordpress/components';
+import {
+ AmountControl,
+ CurrencyControl,
+ Preview,
+ SetupNotice,
+ StyleControls,
+ editorData,
+} from '../components';
+
+const Edit = ( { name, attributes, setAttributes } ) => (
+ <>
+
+
+
+ setAttributes( { amount } ) }
+ />
+ setAttributes( { currency } ) }
+ />
+
+ setAttributes( { buttonText } )
+ }
+ />
+
+
+
+ setAttributes( { collectName } )
+ }
+ />
+
+ setAttributes( { useUserEmail } )
+ }
+ />
+ setAttributes( { width } ) }
+ />
+
+ setAttributes( { showSecured } )
+ }
+ />
+
+
+
+
+ >
+);
+
+export default Edit;
diff --git a/client/blocks/edit/payment-form.js b/client/blocks/edit/payment-form.js
new file mode 100644
index 0000000..794033e
--- /dev/null
+++ b/client/blocks/edit/payment-form.js
@@ -0,0 +1,136 @@
+/**
+ * Editor for flutterwave/payment-form.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ PanelBody,
+ SelectControl,
+ TextControl,
+ TextareaControl,
+ ToggleControl,
+} from '@wordpress/components';
+import {
+ AmountControl,
+ CurrencyControl,
+ Preview,
+ SetupNotice,
+ StyleControls,
+} from '../components';
+
+const Edit = ( { name, attributes, setAttributes } ) => (
+ <>
+
+
+
+ setAttributes( { heading } ) }
+ />
+
+ setAttributes( { description } )
+ }
+ />
+
+ setAttributes( { buttonText } )
+ }
+ />
+
+
+ setAttributes( { amount } ) }
+ />
+ setAttributes( { currency } ) }
+ />
+
+
+
+ setAttributes( { collectName } )
+ }
+ />
+
+ setAttributes( { collectPhone } )
+ }
+ />
+
+ setAttributes( { showSecured } )
+ }
+ />
+
+
+
+
+ >
+);
+
+export default Edit;
diff --git a/client/blocks/edit/payment-methods.js b/client/blocks/edit/payment-methods.js
new file mode 100644
index 0000000..d94cf6d
--- /dev/null
+++ b/client/blocks/edit/payment-methods.js
@@ -0,0 +1,57 @@
+/**
+ * Editor for flutterwave/payment-methods.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
+import { Preview, SetupNotice, editorData } from '../components';
+
+const Edit = ( { name, attributes, setAttributes } ) => {
+ const { settingsUrl } = editorData();
+
+ return (
+ <>
+
+
+
+ setAttributes( { label } ) }
+ />
+
+ setAttributes( { showSecured } )
+ }
+ />
+
+ { __(
+ 'The methods shown are the ones enabled in your Flutterwave settings, so this badge stays in step with checkout.',
+ 'rave-payment-forms'
+ ) }{ ' ' }
+ { settingsUrl && (
+
+ { __(
+ 'Change payment methods',
+ 'rave-payment-forms'
+ ) }
+
+ ) }
+
+
+
+
+ >
+ );
+};
+
+export default Edit;
diff --git a/client/blocks/edit/pricing-card.js b/client/blocks/edit/pricing-card.js
new file mode 100644
index 0000000..57e3d01
--- /dev/null
+++ b/client/blocks/edit/pricing-card.js
@@ -0,0 +1,114 @@
+/**
+ * Editor for flutterwave/pricing-card.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ PanelBody,
+ TextControl,
+ TextareaControl,
+ ToggleControl,
+} from '@wordpress/components';
+import {
+ AmountControl,
+ CurrencyControl,
+ Preview,
+ SetupNotice,
+ StyleControls,
+ editorData,
+} from '../components';
+import { linesToList, listToLines } from '../lib/format';
+
+const Edit = ( { name, attributes, setAttributes } ) => (
+ <>
+
+
+
+ setAttributes( { planName } ) }
+ />
+ setAttributes( { amount } ) }
+ />
+ setAttributes( { currency } ) }
+ />
+ setAttributes( { period } ) }
+ />
+
+ setAttributes( { description } )
+ }
+ />
+
+ setAttributes( { features: linesToList( text ) } )
+ }
+ />
+
+
+
+ setAttributes( { highlighted } )
+ }
+ />
+ setAttributes( { badge } ) }
+ />
+
+ setAttributes( { buttonText } )
+ }
+ />
+
+
+
+
+ >
+);
+
+export default Edit;
diff --git a/client/blocks/editor.scss b/client/blocks/editor.scss
new file mode 100644
index 0000000..1d5a5b1
--- /dev/null
+++ b/client/blocks/editor.scss
@@ -0,0 +1,21 @@
+/**
+ * Editor-only styles for the Flutterwave blocks. The blocks themselves use
+ * assets/css/flw.css, loaded in the editor through each block's editorStyle.
+ */
+
+.flw-block-loading {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ min-height: 120px;
+}
+
+.flw-block-setup {
+ padding: 0 16px 16px;
+}
+
+.flw-block-help {
+ color: #757575;
+ font-size: 12px;
+ margin-top: 12px;
+}
diff --git a/client/blocks/icons.js b/client/blocks/icons.js
new file mode 100644
index 0000000..6379255
--- /dev/null
+++ b/client/blocks/icons.js
@@ -0,0 +1,101 @@
+/**
+ * Block icons.
+ */
+
+import { SVG, Path, Rect, Circle } from '@wordpress/primitives';
+
+const ORANGE = '#ff9b00';
+
+export const buttonIcon = (
+
+
+
+
+);
+
+export const formIcon = (
+
+
+
+
+
+);
+
+export const donationIcon = (
+
+
+
+);
+
+export const pricingIcon = (
+
+
+
+
+
+
+
+);
+
+export const methodsIcon = (
+
+
+
+
+
+);
diff --git a/client/blocks/index.js b/client/blocks/index.js
new file mode 100644
index 0000000..3d005d3
--- /dev/null
+++ b/client/blocks/index.js
@@ -0,0 +1,42 @@
+/**
+ * Registers the Flutterwave blocks in the editor. Rendering happens on the
+ * server (blocks//render.php), so there is no save().
+ */
+
+import { registerBlockType } from '@wordpress/blocks';
+import './editor.scss';
+
+import paymentButton from '../../blocks/payment-button/block.json';
+import paymentForm from '../../blocks/payment-form/block.json';
+import donationForm from '../../blocks/donation-form/block.json';
+import pricingCard from '../../blocks/pricing-card/block.json';
+import paymentMethods from '../../blocks/payment-methods/block.json';
+
+import PaymentButtonEdit from './edit/payment-button';
+import PaymentFormEdit from './edit/payment-form';
+import DonationFormEdit from './edit/donation-form';
+import PricingCardEdit from './edit/pricing-card';
+import PaymentMethodsEdit from './edit/payment-methods';
+
+import {
+ buttonIcon,
+ formIcon,
+ donationIcon,
+ pricingIcon,
+ methodsIcon,
+} from './icons';
+
+[
+ [ paymentButton, PaymentButtonEdit, buttonIcon ],
+ [ paymentForm, PaymentFormEdit, formIcon ],
+ [ donationForm, DonationFormEdit, donationIcon ],
+ [ pricingCard, PricingCardEdit, pricingIcon ],
+ [ paymentMethods, PaymentMethodsEdit, methodsIcon ],
+].forEach( ( [ metadata, edit, icon ] ) => {
+ registerBlockType( metadata.name, {
+ ...metadata,
+ icon,
+ edit,
+ save: () => null,
+ } );
+} );
diff --git a/client/blocks/lib/format.js b/client/blocks/lib/format.js
new file mode 100644
index 0000000..8d4ee4d
--- /dev/null
+++ b/client/blocks/lib/format.js
@@ -0,0 +1,62 @@
+/**
+ * Pure helpers shared by the block editors.
+ */
+
+/**
+ * Split a textarea value into trimmed, non-empty lines.
+ *
+ * @param {string} text One item per line.
+ * @return {string[]} The items.
+ */
+export const linesToList = ( text ) =>
+ String( text || '' )
+ .split( /\r?\n/ )
+ .map( ( line ) => line.trim() )
+ .filter( Boolean );
+
+/**
+ * Join items into a textarea value.
+ *
+ * @param {string[]} list Items.
+ * @return {string} One item per line.
+ */
+export const listToLines = ( list ) =>
+ ( Array.isArray( list ) ? list : [] ).join( '\n' );
+
+/**
+ * Parse an amount typed into a number field. Empty or invalid input is 0.
+ *
+ * @param {string|number} value Raw value.
+ * @return {number} A non-negative amount with at most two decimals.
+ */
+export const parseAmount = ( value ) => {
+ const amount = parseFloat( value );
+
+ if ( ! Number.isFinite( amount ) || amount <= 0 ) {
+ return 0;
+ }
+
+ return Math.round( amount * 100 ) / 100;
+};
+
+/**
+ * The valid suggested amounts in a comma separated list, as the server reads them.
+ *
+ * @param {string} text Comma separated amounts.
+ * @return {number[]} Up to six distinct positive amounts.
+ */
+export const parsePresetAmounts = ( text ) => {
+ const seen = [];
+
+ String( text || '' )
+ .split( ',' )
+ .map( ( item ) => parseAmount( item.trim() ) )
+ .filter( ( amount ) => amount > 0 )
+ .forEach( ( amount ) => {
+ if ( ! seen.includes( amount ) ) {
+ seen.push( amount );
+ }
+ } );
+
+ return seen.slice( 0, 6 );
+};
diff --git a/client/blocks/lib/test/format.test.js b/client/blocks/lib/test/format.test.js
new file mode 100644
index 0000000..a288c40
--- /dev/null
+++ b/client/blocks/lib/test/format.test.js
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import {
+ linesToList,
+ listToLines,
+ parseAmount,
+ parsePresetAmounts,
+} from '../format';
+
+describe( 'linesToList / listToLines', () => {
+ it( 'drops blank lines and trims', () => {
+ expect( linesToList( ' One \n\n Two\r\n ' ) ).toEqual( [
+ 'One',
+ 'Two',
+ ] );
+ } );
+
+ it( 'round-trips', () => {
+ expect( linesToList( listToLines( [ 'A', 'B' ] ) ) ).toEqual( [
+ 'A',
+ 'B',
+ ] );
+ expect( listToLines( undefined ) ).toBe( '' );
+ } );
+} );
+
+describe( 'parseAmount', () => {
+ it.each( [
+ [ '5000', 5000 ],
+ [ '12.345', 12.35 ],
+ [ '', 0 ],
+ [ '-3', 0 ],
+ [ 'abc', 0 ],
+ ] )( '%s → %s', ( value, expected ) => {
+ expect( parseAmount( value ) ).toBe( expected );
+ } );
+} );
+
+describe( 'parsePresetAmounts', () => {
+ it( 'keeps up to six distinct positive amounts, like the server', () => {
+ expect(
+ parsePresetAmounts( '1000, 5000, x, -1, 5000, 1, 2, 3, 4, 5' )
+ ).toEqual( [ 1000, 5000, 1, 2, 3, 4 ] );
+ } );
+} );
diff --git a/composer.json b/composer.json
index 82c319c..3aaef2c 100644
--- a/composer.json
+++ b/composer.json
@@ -15,54 +15,75 @@
}
],
"require-dev": {
- "wp-coding-standards/wpcs": "^2.3",
- "phpcompatibility/phpcompatibility-wp": "*",
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
- "yoast/phpunit-polyfills": "^1.0"
+ "brain/monkey": "^2.7",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.2",
+ "phpcompatibility/phpcompatibility-wp": "^2.1.8",
+ "phpunit/phpunit": "^9.6.33",
+ "squizlabs/php_codesniffer": "^3.13.6",
+ "wp-coding-standards/wpcs": "^3.4.1",
+ "yoast/phpunit-polyfills": "^2.0.5"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
- }
+ },
+ "platform": {
+ "php": "7.4.33"
+ },
+ "sort-packages": true
},
"scripts": {
"test": [
- "./vendor/bin/pest --coverage=coverage --coverage-text"
+ "@test:unit",
+ "@test:integration"
],
"standards:check": [
- "./vendor/bin/phpcs --standard=WordPress --extensions=php --ignore=*/vendor/*,*/tests/*,*/data/*,*/assets/*,*/.docker/*,*/build/*.asset.php,*/node_modules/* ."
+ "phpcs"
],
"standards:fix": [
- "./vendor/bin/phpcbf --standard=WordPress --extensions=php --ignore=*/vendor/*,*/tests/*,*/data/*,*/assets/*,*/.docker/*,*/build/*.asset.php,*/node_modules/* ."
+ "phpcbf"
],
"phpcompatibility:check": [
- "./vendor/bin/phpcs --standard=PHPCompatibilityWP --extensions=php --ignore=*/vendor/*,*/tests/*,*/data/*,*/assets/*,*/.docker/*,*/build/*.asset.php,*/node_modules/* ."
- ],
- "phpcompatibility:fix": [
- "./vendor/bin/phpcbf --standard=PHPCompatibilityWP --extensions=php --ignore=*/vendor/*,*/tests/*,*/data/*,*/assets/*,*/.docker/*,*/build/*.asset.php,*/node_modules/* ."
- ],
- "phpcompatibility:check:php-7-2": [
- "./vendor/bin/phpcs --runtime-set testVersion 7.2- --standard=PHPCompatibilityWP --extensions=php --ignore=vendor/*,*/build/*.asset.php,*/node_modules/* ."
+ "phpcs --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- --extensions=php --ignore=*/vendor/*,*/tests/*,*/data/*,*/assets/*,*/.docker/*,*/build/*.asset.php,*/node_modules/* ."
],
"makepot-audit": [
"wp --allow-root i18n make-pot . --exclude=\".github,.wordpress-org,bin,node_modules,tests,docs\" --slug=rave-payment-forms --debug"
],
"makepot": [
"@makepot-audit --skip-audit"
+ ],
+ "test:unit": [
+ "phpunit --testsuite unit"
+ ],
+ "test:integration": [
+ "phpunit -c phpunit-integration.xml.dist"
+ ],
+ "test:coverage": [
+ "XDEBUG_MODE=coverage phpunit --testsuite unit --coverage-html=coverage --coverage-text"
]
},
"extra": {
"scripts-description": {
- "test": "Run unit tests",
"standards:check": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
"standards:fix": "Fix coding standards warnings/errors automatically with PHP Code Beautifier",
"makepot-audit": "Generate i18n/languages/rave-payment-forms.pot file and run audit",
- "makepot": "Generate i18n/languages/rave-payment-forms.pot file"
+ "makepot": "Generate i18n/languages/rave-payment-forms.pot file",
+ "test": "Run unit and integration tests",
+ "test:unit": "Run fast unit tests (no WordPress needed)",
+ "test:integration": "Run integration tests against the WordPress test suite (see wp-env)",
+ "phpcompatibility:check": "Check PHP 7.4+ compatibility"
}
},
- "minimum-stability": "stable",
"require": {
+ "php": ">=7.4",
"ext-json": "*",
"ext-openssl": "*"
- }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Flutterwave\\WordPress\\Tests\\": "tests/"
+ }
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
diff --git a/composer.lock b/composer.lock
index 465d4eb..fdabce0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,40 +4,161 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "6fb7a634d1ea6a49800694913c5c4e9d",
+ "content-hash": "e60e1b8a2be22a9b7ff55cd7bacbe7d0",
"packages": [],
"packages-dev": [
+ {
+ "name": "antecedent/patchwork",
+ "version": "2.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antecedent/patchwork.git",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=4"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignas Rudaitis",
+ "email": "ignas.rudaitis@gmail.com"
+ }
+ ],
+ "description": "Method redefinition (monkey-patching) functionality for PHP.",
+ "homepage": "https://antecedent.github.io/patchwork/",
+ "keywords": [
+ "aop",
+ "aspect",
+ "interception",
+ "monkeypatching",
+ "redefinition",
+ "runkit",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/antecedent/patchwork/issues",
+ "source": "https://github.com/antecedent/patchwork/tree/2.2.3"
+ },
+ "time": "2025-09-17T09:00:56+00:00"
+ },
+ {
+ "name": "brain/monkey",
+ "version": "2.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Brain-WP/BrainMonkey.git",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "shasum": ""
+ },
+ "require": {
+ "antecedent/patchwork": "^2.1.17",
+ "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10",
+ "php": ">=5.6.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
+ "phpcompatibility/php-compatibility": "^9.3.0",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev",
+ "dev-version/1": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "inc/api.php"
+ ],
+ "psr-4": {
+ "Brain\\Monkey\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Giuseppe Mazzapica",
+ "email": "giuseppe.mazzapica@gmail.com",
+ "homepage": "https://gmazzap.me",
+ "role": "Developer"
+ }
+ ],
+ "description": "Mocking utility for PHP functions and WordPress plugin API",
+ "keywords": [
+ "Monkey Patching",
+ "interception",
+ "mock",
+ "mock functions",
+ "mockery",
+ "patchwork",
+ "redefinition",
+ "runkit",
+ "test",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Brain-WP/BrainMonkey/issues",
+ "source": "https://github.com/Brain-WP/BrainMonkey"
+ },
+ "time": "2026-02-05T09:22:14+00:00"
+ },
{
"name": "dealerdirect/phpcodesniffer-composer-installer",
- "version": "v0.7.2",
+ "version": "v1.2.1",
"source": {
"type": "git",
- "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git",
- "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db"
+ "url": "https://github.com/PHPCSStandards/composer-installer.git",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db",
- "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
"shasum": ""
},
"require": {
- "composer-plugin-api": "^1.0 || ^2.0",
- "php": ">=5.3",
- "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0"
+ "composer-plugin-api": "^2.2",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
},
"require-dev": {
- "composer/composer": "*",
- "php-parallel-lint/php-parallel-lint": "^1.3.1",
- "phpcompatibility/php-compatibility": "^9.0"
+ "composer/composer": "^2.2",
+ "ext-json": "*",
+ "ext-zip": "*",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
+ "yoast/phpunit-polyfills": "^1.0"
},
"type": "composer-plugin",
"extra": {
- "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
+ "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
},
"autoload": {
"psr-4": {
- "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
+ "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -47,17 +168,16 @@
"authors": [
{
"name": "Franck Nijhof",
- "email": "franck.nijhof@dealerdirect.com",
- "homepage": "http://www.frenck.nl",
- "role": "Developer / IT Manager"
+ "email": "opensource@frenck.dev",
+ "homepage": "https://frenck.dev",
+ "role": "Open source developer"
},
{
"name": "Contributors",
- "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors"
+ "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors"
}
],
"description": "PHP_CodeSniffer Standards Composer Installer Plugin",
- "homepage": "http://www.dealerdirect.com",
"keywords": [
"PHPCodeSniffer",
"PHP_CodeSniffer",
@@ -77,10 +197,29 @@
"tests"
],
"support": {
- "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues",
- "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer"
+ "issues": "https://github.com/PHPCSStandards/composer-installer/issues",
+ "security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
+ "source": "https://github.com/PHPCSStandards/composer-installer"
},
- "time": "2022-02-04T12:51:07+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-05-06T08:26:05+00:00"
},
{
"name": "doctrine/instantiator",
@@ -152,18 +291,155 @@
],
"time": "2022-12-30T00:15:36+00:00"
},
+ {
+ "name": "hamcrest/hamcrest-php",
+ "version": "v3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hamcrest/hamcrest-php.git",
+ "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52",
+ "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "php": "^7.4|^8.0"
+ },
+ "replace": {
+ "cordoval/hamcrest-php": "*",
+ "davedevelopment/hamcrest-php": "*",
+ "kodova/hamcrest-php": "*"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "hamcrest"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "This is the PHP port of Hamcrest Matchers",
+ "keywords": [
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0"
+ },
+ "time": "2026-03-17T11:56:53+00:00"
+ },
+ {
+ "name": "mockery/mockery",
+ "version": "1.6.15",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mockery/mockery.git",
+ "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee",
+ "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee",
+ "shasum": ""
+ },
+ "require": {
+ "hamcrest/hamcrest-php": "^2.0 || ^3.0",
+ "php": ">=7.3"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6.36",
+ "symplify/easy-coding-standard": "^13.2.17"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/helpers.php",
+ "library/Mockery.php"
+ ],
+ "psr-4": {
+ "Mockery\\": "library/Mockery"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com",
+ "homepage": "https://github.com/padraic",
+ "role": "Author"
+ },
+ {
+ "name": "Dave Marshall",
+ "email": "dave.marshall@atstsolutions.co.uk",
+ "homepage": "https://davedevelopment.co.uk",
+ "role": "Developer"
+ },
+ {
+ "name": "Nathanael Esayeas",
+ "email": "nathanael.esayeas@protonmail.com",
+ "homepage": "https://github.com/ghostwriter",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Mockery is a simple yet flexible PHP mock object framework",
+ "homepage": "https://github.com/mockery/mockery",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "library",
+ "mock",
+ "mock objects",
+ "mockery",
+ "stub",
+ "test",
+ "test double",
+ "testing"
+ ],
+ "support": {
+ "docs": "https://docs.mockery.io/",
+ "issues": "https://github.com/mockery/mockery/issues",
+ "rss": "https://github.com/mockery/mockery/releases.atom",
+ "security": "https://github.com/mockery/mockery/security/advisories",
+ "source": "https://github.com/mockery/mockery"
+ },
+ "time": "2026-08-19T19:37:52+00:00"
+ },
{
"name": "myclabs/deep-copy",
- "version": "1.11.1",
+ "version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c"
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
- "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
@@ -171,11 +447,12 @@
},
"conflict": {
"doctrine/collections": "<1.6.8",
- "doctrine/common": "<2.13.3 || >=3,<3.2.2"
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
@@ -201,7 +478,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1"
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
@@ -209,29 +486,30 @@
"type": "tidelift"
}
],
- "time": "2023-03-08T13:26:56+00:00"
+ "time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nikic/php-parser",
- "version": "v4.15.4",
+ "version": "v5.9.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290"
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6bb5176bc4af8bcb7d926f88718db9b96a2d4290",
- "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
"shasum": ""
},
"require": {
+ "ext-json": "*",
"ext-tokenizer": "*",
- "php": ">=7.0"
+ "php": ">=7.4"
},
"require-dev": {
"ircmaxell/php-yacc": "^0.0.7",
- "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+ "phpunit/phpunit": "^9.0"
},
"bin": [
"bin/php-parse"
@@ -239,7 +517,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.9-dev"
+ "dev-master": "5.x-dev"
}
},
"autoload": {
@@ -263,26 +541,27 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.4"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
},
- "time": "2023-03-05T19:49:14+00:00"
+ "time": "2026-09-13T18:51:52+00:00"
},
{
"name": "phar-io/manifest",
- "version": "2.0.3",
+ "version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/phar-io/manifest.git",
- "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
- "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
"shasum": ""
},
"require": {
"ext-dom": "*",
+ "ext-libxml": "*",
"ext-phar": "*",
"ext-xmlwriter": "*",
"phar-io/version": "^3.0.1",
@@ -323,9 +602,15 @@
"description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
"support": {
"issues": "https://github.com/phar-io/manifest/issues",
- "source": "https://github.com/phar-io/manifest/tree/2.0.3"
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
},
- "time": "2021-07-20T11:28:43+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
},
{
"name": "phar-io/version",
@@ -442,28 +727,28 @@
},
{
"name": "phpcompatibility/phpcompatibility-paragonie",
- "version": "1.3.2",
+ "version": "1.3.4",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git",
- "reference": "bba5a9dfec7fcfbd679cfaf611d86b4d3759da26"
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/bba5a9dfec7fcfbd679cfaf611d86b4d3759da26",
- "reference": "bba5a9dfec7fcfbd679cfaf611d86b4d3759da26",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
"shasum": ""
},
"require": {
"phpcompatibility/php-compatibility": "^9.0"
},
"require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"paragonie/random_compat": "dev-master",
"paragonie/sodium_compat": "dev-master"
},
"suggest": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
@@ -493,33 +778,53 @@
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy",
"source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie"
},
- "time": "2022-10-25T01:46:02+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-09-19T17:43:28+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-wp",
- "version": "2.1.4",
+ "version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git",
- "reference": "b6c1e3ee1c35de6c41a511d5eb9bd03e447480a5"
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/b6c1e3ee1c35de6c41a511d5eb9bd03e447480a5",
- "reference": "b6c1e3ee1c35de6c41a511d5eb9bd03e447480a5",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa",
"shasum": ""
},
"require": {
"phpcompatibility/php-compatibility": "^9.0",
- "phpcompatibility/phpcompatibility-paragonie": "^1.0"
+ "phpcompatibility/phpcompatibility-paragonie": "^1.0",
+ "squizlabs/php_codesniffer": "^3.3"
},
"require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7"
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0"
},
"suggest": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
@@ -548,41 +853,235 @@
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy",
"source": "https://github.com/PHPCompatibility/PHPCompatibilityWP"
},
- "time": "2022-10-24T09:00:36+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-10-18T00:05:59+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsextra",
+ "version": "1.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "phpcsstandards/phpcsdevtools": "^1.2.1",
+ "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors"
+ }
+ ],
+ "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSExtra"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-07-27T11:13:17+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsutils",
+ "version": "1.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "shasum": ""
+ },
+ "require": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "ext-filter": "*",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "PHPCSUtils/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors"
+ }
+ ],
+ "description": "A suite of utility functions for use with PHP_CodeSniffer",
+ "homepage": "https://phpcsutils.com/",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "phpcs3",
+ "phpcs4",
+ "standards",
+ "static analysis",
+ "tokens",
+ "utility"
+ ],
+ "support": {
+ "docs": "https://phpcsutils.com/",
+ "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSUtils"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-07-27T10:28:41+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "9.2.26",
+ "version": "9.2.32",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1"
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1",
- "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
- "nikic/php-parser": "^4.15",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
"php": ">=7.3",
- "phpunit/php-file-iterator": "^3.0.3",
- "phpunit/php-text-template": "^2.0.2",
- "sebastian/code-unit-reverse-lookup": "^2.0.2",
- "sebastian/complexity": "^2.0",
- "sebastian/environment": "^5.1.2",
- "sebastian/lines-of-code": "^1.0.3",
- "sebastian/version": "^3.0.1",
- "theseer/tokenizer": "^1.2.0"
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-text-template": "^2.0.4",
+ "sebastian/code-unit-reverse-lookup": "^2.0.3",
+ "sebastian/complexity": "^2.0.3",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/lines-of-code": "^1.0.4",
+ "sebastian/version": "^3.0.2",
+ "theseer/tokenizer": "^1.2.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^9.6"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -591,7 +1090,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "9.2-dev"
+ "dev-main": "9.2.x-dev"
}
},
"autoload": {
@@ -619,7 +1118,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26"
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
},
"funding": [
{
@@ -627,7 +1127,7 @@
"type": "github"
}
],
- "time": "2023-03-06T12:58:08+00:00"
+ "time": "2024-08-22T04:23:01+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -872,45 +1372,45 @@
},
{
"name": "phpunit/phpunit",
- "version": "9.6.8",
+ "version": "9.6.37",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e"
+ "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/17d621b3aff84d0c8b62539e269e87d8d5baa76e",
- "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f",
+ "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f",
"shasum": ""
},
"require": {
- "doctrine/instantiator": "^1.3.1 || ^2",
+ "doctrine/instantiator": "^1.5.0 || ^2",
"ext-dom": "*",
+ "ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
- "ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.10.1",
- "phar-io/manifest": "^2.0.3",
- "phar-io/version": "^3.0.2",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
"php": ">=7.3",
- "phpunit/php-code-coverage": "^9.2.13",
- "phpunit/php-file-iterator": "^3.0.5",
+ "phpunit/php-code-coverage": "^9.2.32",
+ "phpunit/php-file-iterator": "^3.0.6",
"phpunit/php-invoker": "^3.1.1",
- "phpunit/php-text-template": "^2.0.3",
- "phpunit/php-timer": "^5.0.2",
- "sebastian/cli-parser": "^1.0.1",
- "sebastian/code-unit": "^1.0.6",
- "sebastian/comparator": "^4.0.8",
- "sebastian/diff": "^4.0.3",
- "sebastian/environment": "^5.1.3",
- "sebastian/exporter": "^4.0.5",
- "sebastian/global-state": "^5.0.1",
- "sebastian/object-enumerator": "^4.0.3",
- "sebastian/resource-operations": "^3.0.3",
- "sebastian/type": "^3.2",
+ "phpunit/php-text-template": "^2.0.4",
+ "phpunit/php-timer": "^5.0.3",
+ "sebastian/cli-parser": "^1.0.2",
+ "sebastian/code-unit": "^1.0.8",
+ "sebastian/comparator": "^4.0.10",
+ "sebastian/diff": "^4.0.6",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/exporter": "^4.0.9",
+ "sebastian/global-state": "^5.0.8",
+ "sebastian/object-enumerator": "^4.0.4",
+ "sebastian/resource-operations": "^3.0.4",
+ "sebastian/type": "^3.2.1",
"sebastian/version": "^3.0.2"
},
"suggest": {
@@ -955,36 +1455,28 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.8"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37"
},
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
}
],
- "time": "2023-05-11T05:14:45+00:00"
+ "time": "2026-09-23T09:59:59+00:00"
},
{
"name": "sebastian/cli-parser",
- "version": "1.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
- "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
"shasum": ""
},
"require": {
@@ -1019,7 +1511,7 @@
"homepage": "https://github.com/sebastianbergmann/cli-parser",
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
},
"funding": [
{
@@ -1027,7 +1519,7 @@
"type": "github"
}
],
- "time": "2020-09-28T06:08:49+00:00"
+ "time": "2024-03-02T06:27:43+00:00"
},
{
"name": "sebastian/code-unit",
@@ -1142,16 +1634,16 @@
},
{
"name": "sebastian/comparator",
- "version": "4.0.8",
+ "version": "4.0.10",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
"shasum": ""
},
"require": {
@@ -1204,32 +1696,44 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
- "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
}
],
- "time": "2022-09-14T12:41:17+00:00"
+ "time": "2026-01-24T09:22:56+00:00"
},
{
"name": "sebastian/complexity",
- "version": "2.0.2",
+ "version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
- "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.7",
+ "nikic/php-parser": "^4.18 || ^5.0",
"php": ">=7.3"
},
"require-dev": {
@@ -1261,7 +1765,7 @@
"homepage": "https://github.com/sebastianbergmann/complexity",
"support": {
"issues": "https://github.com/sebastianbergmann/complexity/issues",
- "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
},
"funding": [
{
@@ -1269,20 +1773,20 @@
"type": "github"
}
],
- "time": "2020-10-26T15:52:27+00:00"
+ "time": "2023-12-22T06:19:30+00:00"
},
{
"name": "sebastian/diff",
- "version": "4.0.5",
+ "version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131"
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
- "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
"shasum": ""
},
"require": {
@@ -1327,7 +1831,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
- "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5"
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
},
"funding": [
{
@@ -1335,7 +1839,7 @@
"type": "github"
}
],
- "time": "2023-05-07T05:35:17+00:00"
+ "time": "2024-03-02T06:30:58+00:00"
},
{
"name": "sebastian/environment",
@@ -1402,16 +1906,16 @@
},
{
"name": "sebastian/exporter",
- "version": "4.0.5",
+ "version": "4.0.9",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
+ "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
- "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
+ "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
"shasum": ""
},
"require": {
@@ -1467,28 +1971,40 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
- "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
}
],
- "time": "2022-09-14T06:03:37+00:00"
+ "time": "2026-08-11T04:55:59+00:00"
},
{
"name": "sebastian/global-state",
- "version": "5.0.5",
+ "version": "5.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2"
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2",
- "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
"shasum": ""
},
"require": {
@@ -1531,32 +2047,44 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
- "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5"
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
}
],
- "time": "2022-02-14T08:28:10+00:00"
+ "time": "2025-08-10T07:10:35+00:00"
},
{
"name": "sebastian/lines-of-code",
- "version": "1.0.3",
+ "version": "1.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
- "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.6",
+ "nikic/php-parser": "^4.18 || ^5.0",
"php": ">=7.3"
},
"require-dev": {
@@ -1588,7 +2116,7 @@
"homepage": "https://github.com/sebastianbergmann/lines-of-code",
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
},
"funding": [
{
@@ -1596,7 +2124,7 @@
"type": "github"
}
],
- "time": "2020-11-28T06:42:11+00:00"
+ "time": "2023-12-22T06:20:34+00:00"
},
{
"name": "sebastian/object-enumerator",
@@ -1712,16 +2240,16 @@
},
{
"name": "sebastian/recursion-context",
- "version": "4.0.5",
+ "version": "4.0.7",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1"
+ "reference": "c85be6922b7fd365942b986b9a50397d65407611"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611",
+ "reference": "c85be6922b7fd365942b986b9a50397d65407611",
"shasum": ""
},
"require": {
@@ -1763,28 +2291,40 @@
"homepage": "https://github.com/sebastianbergmann/recursion-context",
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5"
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
}
],
- "time": "2023-02-03T06:07:39+00:00"
+ "time": "2026-08-11T05:25:24+00:00"
},
{
"name": "sebastian/resource-operations",
- "version": "3.0.3",
+ "version": "3.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/resource-operations.git",
- "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
- "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
"shasum": ""
},
"require": {
@@ -1796,7 +2336,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-main": "3.0-dev"
}
},
"autoload": {
@@ -1817,8 +2357,7 @@
"description": "Provides a list of PHP built-in functions that operate on resources",
"homepage": "https://www.github.com/sebastianbergmann/resource-operations",
"support": {
- "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
- "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
},
"funding": [
{
@@ -1826,7 +2365,7 @@
"type": "github"
}
],
- "time": "2020-09-28T06:45:17+00:00"
+ "time": "2024-03-14T16:00:52+00:00"
},
{
"name": "sebastian/type",
@@ -1939,16 +2478,16 @@
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.7.2",
+ "version": "3.13.6",
"source": {
"type": "git",
- "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
- "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879"
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879",
- "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
+ "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
"shasum": ""
},
"require": {
@@ -1958,18 +2497,13 @@
"php": ">=5.4.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"bin": [
- "bin/phpcs",
- "bin/phpcbf"
+ "bin/phpcbf",
+ "bin/phpcs"
],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
@@ -1977,35 +2511,62 @@
"authors": [
{
"name": "Greg Sherwood",
- "role": "lead"
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
}
],
"description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
- "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
"keywords": [
"phpcs",
"standards",
"static analysis"
],
"support": {
- "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
- "source": "https://github.com/squizlabs/PHP_CodeSniffer",
- "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
},
- "time": "2023-02-22T23:07:41+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-08-06T00:17:32+00:00"
},
{
"name": "theseer/tokenizer",
- "version": "1.2.1",
+ "version": "1.3.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
- "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"shasum": ""
},
"require": {
@@ -2034,7 +2595,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
},
"funding": [
{
@@ -2042,34 +2603,42 @@
"type": "github"
}
],
- "time": "2021-07-28T10:34:58+00:00"
+ "time": "2025-11-17T20:03:58+00:00"
},
{
"name": "wp-coding-standards/wpcs",
- "version": "2.3.0",
+ "version": "3.4.1",
"source": {
"type": "git",
"url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
- "reference": "7da1894633f168fe244afc6de00d141f27517b62"
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7da1894633f168fe244afc6de00d141f27517b62",
- "reference": "7da1894633f168fe244afc6de00d141f27517b62",
+ "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf",
+ "reference": "ec2ff942335f33683a5957a85d138753876a05cf",
"shasum": ""
},
"require": {
- "php": ">=5.4",
- "squizlabs/php_codesniffer": "^3.3.1"
+ "ext-filter": "*",
+ "ext-libxml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlreader": "*",
+ "php": ">=7.2",
+ "phpcsstandards/phpcsextra": "^1.5.1",
+ "phpcsstandards/phpcsutils": "^1.2.3",
+ "squizlabs/php_codesniffer": "^3.13.5"
},
"require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || ^0.6",
- "phpcompatibility/php-compatibility": "^9.0",
- "phpcsstandards/phpcsdevtools": "^1.0",
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
+ "phpcsstandards/phpcsdevtools": "^1.2.0",
+ "phpunit/phpunit": "^8.0 || ^9.0"
},
"suggest": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.6 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically."
+ "ext-iconv": "For improved results",
+ "ext-mbstring": "For improved results"
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
@@ -2086,6 +2655,7 @@
"keywords": [
"phpcs",
"standards",
+ "static analysis",
"wordpress"
],
"support": {
@@ -2093,33 +2663,41 @@
"source": "https://github.com/WordPress/WordPress-Coding-Standards",
"wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki"
},
- "time": "2020-05-13T23:57:56+00:00"
+ "funding": [
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "custom"
+ }
+ ],
+ "time": "2026-07-27T11:53:23+00:00"
},
{
"name": "yoast/phpunit-polyfills",
- "version": "1.0.5",
+ "version": "2.0.5",
"source": {
"type": "git",
"url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
- "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171"
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/3b59adeef77fb1c03ff5381dbb9d68b0aaff3171",
- "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
"shasum": ""
},
"require": {
- "php": ">=5.4",
- "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
+ "php": ">=5.6",
+ "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0"
},
"require-dev": {
- "yoast/yoastcs": "^2.3.0"
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "yoast/yoastcs": "^3.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.x-dev"
+ "dev-main": "4.x-dev"
}
},
"autoload": {
@@ -2151,20 +2729,25 @@
],
"support": {
"issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues",
+ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
"source": "https://github.com/Yoast/PHPUnit-Polyfills"
},
- "time": "2023-03-30T23:39:05+00:00"
+ "time": "2025-08-10T05:13:49+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
- "prefer-stable": false,
+ "stability-flags": {},
+ "prefer-stable": true,
"prefer-lowest": false,
"platform": {
+ "php": ">=7.4",
"ext-json": "*",
"ext-openssl": "*"
},
- "platform-dev": [],
- "plugin-api-version": "2.3.0"
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "7.4.33"
+ },
+ "plugin-api-version": "2.9.0"
}
diff --git a/eslint.config.cjs b/eslint.config.cjs
new file mode 100644
index 0000000..31f7a70
--- /dev/null
+++ b/eslint.config.cjs
@@ -0,0 +1,52 @@
+const globals = require( 'globals' );
+const wpConfig = require( '@wordpress/scripts/config/eslint.config.cjs' );
+const wpPlugin = require( '@wordpress/eslint-plugin' );
+
+module.exports = [
+ {
+ ignores: [
+ '**/*.min.js',
+ 'coverage/**',
+ 'playwright-report/**',
+ 'test-results/**',
+ 'test-reports/**',
+ ],
+ },
+ ...wpConfig,
+ {
+ files: [ 'assets/js/**/*.js' ],
+ languageOptions: {
+ sourceType: 'script',
+ globals: {
+ ...globals.browser,
+ ...globals.jquery,
+ flw_pay_options: 'readonly',
+ tinymce: 'readonly',
+ },
+ },
+ },
+ {
+ // Provided by WordPress at runtime (see build/*.asset.php), so they are
+ // externals rather than installed dependencies.
+ files: [ 'client/**/*.js' ],
+ settings: {
+ 'import/core-modules': [
+ '@wordpress/block-editor',
+ '@wordpress/blocks',
+ '@wordpress/components',
+ '@wordpress/primitives',
+ '@wordpress/server-side-render',
+ ],
+ },
+ },
+ ...wpPlugin.configs[ 'test-playwright' ].map( ( config ) => ( {
+ ...config,
+ files: [ 'tests/e2e/**/*.js', 'playwright.config.js' ],
+ // The report script's own tests run in Vitest, not Playwright.
+ ignores: [ 'tests/e2e/report/*.test.js' ],
+ } ) ),
+ {
+ files: [ 'tests/e2e/**/*.js', 'playwright.config.js', '*.cjs' ],
+ languageOptions: { globals: { ...globals.node } },
+ },
+];
diff --git a/i18n/languages/rave-payment-forms.pot b/i18n/languages/rave-payment-forms.pot
index f860ce6..d19cc44 100644
--- a/i18n/languages/rave-payment-forms.pot
+++ b/i18n/languages/rave-payment-forms.pot
@@ -1,62 +1,261 @@
-# Copyright (C) 2023 Flutterwave Developers
+# Copyright (C) 2026 Flutterwave Developers
# This file is distributed under the MIT License.
msgid ""
msgstr ""
-"Project-Id-Version: Flutterwave Payments 1.0.6\n"
+"Project-Id-Version: Flutterwave Payments 1.1.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/rave-payment-forms\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"POT-Creation-Date: 2023-05-19T08:02:43+00:00\n"
+"POT-Creation-Date: 2026-09-24T16:09:00+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"X-Generator: WP-CLI 2.5.0\n"
+"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: rave-payment-forms\n"
#. Plugin Name of the plugin
-#: includes/class-flw-admin-settings.php:139
+#: rave-payment-forms.php
+#: includes/class-flw-admin-settings.php:235
+#: includes/class-flw-admin-settings.php:432
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:29
+#: client/admin/screens/Welcome.js:26
+#: client/admin/screens/Wizard.js:31
msgid "Flutterwave Payments"
msgstr ""
#. Plugin URI of the plugin
+#: rave-payment-forms.php
msgid "http://flutterwave.com/"
msgstr ""
#. Description of the plugin
+#: rave-payment-forms.php
msgid "Flutterwave payment gateway forms, accept local and international payments securely."
msgstr ""
#. Author of the plugin
+#: rave-payment-forms.php
msgid "Flutterwave Developers"
msgstr ""
#. Author URI of the plugin
+#: rave-payment-forms.php
msgid "https://developer.flutterwave.com/"
msgstr ""
-#: includes/class-flutterwave-payments.php:189
-msgid "Flutterwave Payments is installed. - "
+#: blocks/payment-button/render.php:35
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:124
+#: client/blocks/edit/payment-button.js:45
+msgid "Pay {amount}"
msgstr ""
-#: includes/class-flutterwave-payments.php:191
-msgid "Enter your Flutterwave \"Pay Checkout\" Public Key and Secret Key to start accepting payments"
+#: blocks/payment-methods/render.php:22
+msgid "Cards"
msgstr ""
-#: includes/class-flw-admin-settings.php:150
+#: blocks/payment-methods/render.php:23
+msgid "Bank transfer"
+msgstr ""
+
+#: blocks/payment-methods/render.php:24
+msgid "Mobile money"
+msgstr ""
+
+#: blocks/payment-methods/render.php:25
+#: build/admin.js:2
+#: client/admin/lib/constants.js:104
+msgid "Apple Pay"
+msgstr ""
+
+#: blocks/payment-methods/render.php:26
+#: build/admin.js:2
+#: client/admin/lib/constants.js:112
+msgid "Google Pay"
+msgstr ""
+
+#: blocks/payment-methods/render.php:27
+msgid "OPay"
+msgstr ""
+
+#: blocks/payment-methods/render.php:28
+#: build/admin.js:2
+#: client/admin/lib/constants.js:80
+msgid "Stablecoin"
+msgstr ""
+
+#: blocks/payment-methods/render.php:29
+#: build/admin.js:2
+#: client/admin/lib/constants.js:133
+msgid "USSD"
+msgstr ""
+
+#: blocks/payment-methods/render.php:30
+#: build/admin.js:2
+#: client/admin/lib/constants.js:134
+msgid "QR"
+msgstr ""
+
+#: blocks/payment-methods/render.php:31
+#: build/admin.js:2
+#: client/admin/lib/constants.js:135
+msgid "NQR"
+msgstr ""
+
+#: blocks/payment-methods/render.php:32
+#: build/admin.js:2
+#: client/admin/lib/constants.js:136
+msgid "Credit"
+msgstr ""
+
+#: blocks/payment-methods/render.php:33
+#: build/admin.js:2
+#: client/admin/lib/constants.js:137
+msgid "Barter"
+msgstr ""
+
+#. translators: %s: Flutterwave, in bold.
+#: blocks/payment-methods/render.php:61
+#, php-format
+msgid "Payments secured by %s"
+msgstr ""
+
+#: blocks/pricing-card/render.php:62
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:98
+msgid "Get started"
+msgstr ""
+
+#: includes/admin/class-flw-forms-controller.php:295
+msgid "(no title)"
+msgstr ""
+
+#: includes/admin/class-flw-forms-controller.php:344
+msgid "Unknown form type."
+msgstr ""
+
+#: includes/admin/class-flw-forms-controller.php:356
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:26
+msgid "Payment"
+msgstr ""
+
+#: includes/admin/class-flw-payments-controller.php:419
+#: includes/admin/class-flw-payments-controller.php:450
+msgid "Transaction not found."
+msgstr ""
+
+#: includes/admin/class-flw-payments-controller.php:426
+msgid "Flutterwave has no record of this payment yet."
+msgstr ""
+
+#: includes/admin/class-flw-payments-controller.php:482
+msgid "Sorry, you are not allowed to export transactions."
+msgstr ""
+
+#: includes/blocks/class-flw-blocks.php:98
+#: includes/integrations/class-flw-edd-gateway.php:74
+#: includes/integrations/givewp/class-flw-givewp-gateway.php:56
+msgid "Flutterwave"
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:214
+msgid "Flutterwave Payments is installed."
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:215
+msgid "Connect your Flutterwave account to start accepting payments with your forms."
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:217
+#: build/blocks.js:1
+#: client/blocks/components.js:76
+msgid "Set up Flutterwave"
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:259
+msgid "Too many payment attempts. Please wait a few minutes and try again."
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:287
+msgid "Invalid payment type."
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:393
+msgid "Unable to set up a recurring donation. Please try again."
+msgstr ""
+
+#: includes/class-flutterwave-payments.php:423
+#: includes/integrations/class-flw-hosted-checkout.php:105
+msgid "Unable to start the payment. Please try again."
+msgstr ""
+
+#: includes/class-flw-admin-settings.php:246
msgid "Flutterwave Payments Settings"
msgstr ""
-#: includes/class-flw-admin-settings.php:151
+#: includes/class-flw-admin-settings.php:247
+#: build/blocks.js:1
+#: client/blocks/edit/payment-methods.js:17
msgid "Settings"
msgstr ""
-#: includes/class-flw-payment-list.php:45
-msgid "Payment List"
+#: includes/class-flw-admin-settings.php:255
+#: includes/class-flw-admin-settings.php:256
+#: build/admin.js:3
+#: build/admin.js:4
+#: client/admin/lib/tour.js:50
+#: client/admin/screens/PaymentForms.js:148
+msgid "Payment Forms"
+msgstr ""
+
+#: includes/class-flw-admin-settings.php:264
+#: includes/class-flw-admin-settings.php:265
+#: build/admin.js:3
+#: build/admin.js:8
+#: client/admin/lib/tour.js:59
+#: client/admin/screens/Transactions.js:434
+msgid "Transactions"
+msgstr ""
+
+#: includes/class-flw-admin-settings.php:273
+#: includes/class-flw-admin-settings.php:274
+#: includes/integrations/class-flw-thirdparty-integrations.php:162
+#: build/admin.js:3
+#: build/admin.js:18
+#: client/admin/lib/tour.js:68
+#: client/admin/screens/Integrations.js:329
+#: client/admin/screens/Integrations.js:340
+msgid "Integrations"
+msgstr ""
+
+#: includes/class-flw-admin-settings.php:433
+msgid "The settings screen has not been built. Run `npm run build:admin` in the plugin folder, or install the plugin from WordPress.org."
+msgstr ""
+
+#: includes/class-flw-form-config.php:50
+#: includes/class-flw-form-config.php:56
+msgid "This payment form is invalid or has expired. Please reload the page and try again."
+msgstr ""
+
+#: includes/class-flw-form-config.php:81
+msgid "Please enter a valid amount."
+msgstr ""
+
+#: includes/class-flw-form-config.php:87
+msgid "The selected currency is not accepted by this form."
msgstr ""
#: includes/class-flw-payment-list.php:46
-#: includes/class-flw-payment-list.php:277
+msgid "Payment List"
+msgstr ""
+
+#: includes/class-flw-payment-list.php:47
+#: includes/class-flw-payment-list.php:257
msgid "Payment Lists"
msgstr ""
@@ -64,93 +263,263 @@ msgstr ""
msgid "No payments have been made yet."
msgstr ""
-#: includes/class-flw-payment-list.php:141
+#: includes/class-flw-payment-list.php:146
msgid "Transaction Ref"
msgstr ""
-#: includes/class-flw-payment-list.php:142
+#: includes/class-flw-payment-list.php:147
+#: build/admin.js:8
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:342
+#: client/admin/screens/Transactions.js:475
msgid "Customer"
msgstr ""
-#: includes/class-flw-payment-list.php:143
+#: includes/class-flw-payment-list.php:148
msgid "Customer Fullname"
msgstr ""
-#: includes/class-flw-payment-list.php:144
-#: includes/shortcodes/class-abstract-flw-shortcode.php:184
+#: includes/class-flw-payment-list.php:149
+#: includes/shortcodes/class-abstract-flw-shortcode.php:185
#: includes/vc-elements/class-flw-vc-simple-form.php:47
-#: views/donation-payment.php:61
-#: views/donation-payment.php:62
+#: views/donation-payment.php:71
+#: views/donation-payment.php:79
+#: build/admin.js:4
+#: build/admin.js:5
+#: build/admin.js:9
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:118
+#: client/admin/screens/PaymentForms.js:240
+#: client/admin/screens/Transactions.js:478
+#: client/blocks/edit/payment-button.js:28
+#: client/blocks/edit/payment-form.js:61
msgid "Amount"
msgstr ""
-#: includes/class-flw-payment-list.php:145
-#: includes/shortcodes/class-abstract-flw-shortcode.php:191
-#: includes/shortcodes/class-abstract-flw-shortcode.php:198
-#: views/donation-payment.php:67
+#: includes/class-flw-payment-list.php:150
+#: includes/shortcodes/class-abstract-flw-shortcode.php:192
+#: includes/shortcodes/class-abstract-flw-shortcode.php:199
+#: views/donation-payment.php:84
+#: build/admin.js:4
+#: build/admin.js:8
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:77
+#: client/admin/screens/Transactions.js:136
+#: client/blocks/components.js:124
msgid "Currency"
msgstr ""
-#: includes/class-flw-payment-list.php:146
+#: includes/class-flw-payment-list.php:151
+#: build/admin.js:5
+#: build/admin.js:8
+#: build/admin.js:9
+#: client/admin/screens/PaymentForms.js:246
+#: client/admin/screens/Transactions.js:126
+#: client/admin/screens/Transactions.js:484
msgid "Status"
msgstr ""
-#: includes/class-flw-payment-list.php:147
+#: includes/class-flw-payment-list.php:152
+#: build/admin.js:8
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:348
+#: client/admin/screens/Transactions.js:481
msgid "Date"
msgstr ""
-#: includes/class-flw-payment-list.php:207
-msgid "Transaction List"
+#: includes/class-flw-payment-list.php:258
+msgid "Flutterwave payment lists"
msgstr ""
-#: includes/class-flw-payment-list.php:208
-msgid "Transactions"
+#: includes/class-flw-payment-record.php:133
+msgid "Unknown payment record."
msgstr ""
-#: includes/class-flw-payment-list.php:278
-msgid "Flutterwave payment lists"
+#: includes/class-flw-payment-record.php:141
+msgid "Transaction does not belong to this payment record."
+msgstr ""
+
+#: includes/class-flw-payment-record.php:257
+msgid "Invalid transaction id."
+msgstr ""
+
+#: includes/class-flw-payment-record.php:272
+msgid "Invalid transaction reference."
+msgstr ""
+
+#: includes/class-flw-payment-record.php:306
+msgid "Unable to verify transaction."
msgstr ""
#: includes/class-flw-shortcodes.php:38
-msgid "Please configure Flutterwave Payments settings correctly. API keys are still missing."
+msgid "Connect your Flutterwave account to show this payment form."
+msgstr ""
+
+#: includes/class-flw-shortcodes.php:47
+msgid "Add your redirect pages to show this payment form."
+msgstr ""
+
+#: includes/class-flw-shortcodes.php:67
+msgid "Finish setting up Flutterwave →"
+msgstr ""
+
+#: includes/integrations/class-flw-edd-gateway.php:108
+msgid "You will be taken to Flutterwave to pay securely by card, bank transfer or mobile money."
+msgstr ""
+
+#: includes/integrations/class-flw-edd-gateway.php:121
+msgid "Nonce verification has failed"
+msgstr ""
+
+#: includes/integrations/class-flw-edd-gateway.php:121
+msgid "Error"
+msgstr ""
+
+#: includes/integrations/class-flw-edd-gateway.php:144
+#: includes/integrations/class-flw-edd-gateway.php:171
+msgid "Payment Error"
+msgstr ""
+
+#: includes/integrations/class-flw-edd-gateway.php:144
+msgid "Flutterwave: the order could not be created before payment."
+msgstr ""
+
+#. translators: %s: order number.
+#: includes/integrations/class-flw-edd-gateway.php:162
+#, php-format
+msgid "Order #%s"
+msgstr ""
+
+#. translators: %s: reason.
+#: includes/integrations/class-flw-edd-gateway.php:258
+#: includes/integrations/class-flw-givewp.php:223
+#, php-format
+msgid "Flutterwave payment not accepted: %s"
+msgstr ""
+
+#. translators: %s: Flutterwave transaction id.
+#: includes/integrations/class-flw-edd-gateway.php:264
+#: includes/integrations/class-flw-givewp.php:233
+#, php-format
+msgid "Paid with Flutterwave. Transaction ID: %s"
+msgstr ""
+
+#: includes/integrations/class-flw-hosted-checkout.php:65
+msgid "Flutterwave is not set up yet."
+msgstr ""
+
+#: includes/integrations/class-flw-hosted-checkout.php:123
+msgid "The payment reference does not match this order."
+msgstr ""
+
+#: includes/integrations/class-flw-hosted-checkout.php:127
+msgid "The payment was not successful."
+msgstr ""
+
+#: includes/integrations/class-flw-hosted-checkout.php:131
+msgid "The payment was made in a different currency."
+msgstr ""
+
+#: includes/integrations/class-flw-hosted-checkout.php:136
+msgid "The amount paid is less than the amount due."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:152
+msgid "Take Flutterwave payments at your WooCommerce checkout with the official Flutterwave WooCommerce extension."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:170
+msgid "Sell ebooks, software, music and other downloads, and let customers pay with Flutterwave at checkout."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:183
+msgid "Collect one-off donations through GiveWP forms and campaigns, paid with Flutterwave."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:192
+msgid "Add a Flutterwave payment field to contact, order and registration forms."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:193
+msgid "Charge for form submissions such as applications, bookings and orders."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:194
+msgid "Ask for payment before a Contact Form 7 submission is sent."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:195
+msgid "Sell membership levels and restrict content to paying members."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:196
+msgid "Take membership and subscription payments with Flutterwave."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:197
+msgid "Sell online courses and let students pay in their local currency."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:198
+msgid "Charge for courses and course bundles built with Tutor LMS."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:199
+msgid "Sell tickets for events managed with The Events Calendar."
+msgstr ""
+
+#: includes/integrations/class-flw-integrations.php:316
+msgid "This integration cannot be switched on here."
msgstr ""
-#: includes/class-flw-shortcodes.php:48
-msgid "Please configure Flutterwave Payments settings correctly. Redirect Urls are missing."
+#: includes/integrations/class-flw-integrations.php:323
+msgid "Install and activate the plugin first."
msgstr ""
#: includes/integrations/class-flw-thirdparty-integrations.php:161
msgid "Flutterwave Payments Integrations"
msgstr ""
-#: includes/integrations/class-flw-thirdparty-integrations.php:162
-msgid "Integrations"
+#: includes/integrations/givewp/class-flw-givewp-gateway.php:125
+msgid "Refund this donation from your Flutterwave dashboard."
msgstr ""
-#: includes/shortcodes/class-abstract-flw-shortcode.php:177
-#: views/donation-payment.php:33
-#: views/donation-payment.php:34
-msgid "Email"
+#: includes/integrations/givewp/class-flw-givewp-gateway.php:134
+msgid "You will be taken to Flutterwave to donate securely by card, bank transfer or mobile money."
msgstr ""
-#: includes/shortcodes/class-abstract-flw-shortcode.php:205
-msgid "Full Name"
+#: includes/shortcodes/class-abstract-flw-shortcode.php:178
+#: views/donation-payment.php:41
+#: views/donation-payment.php:42
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:345
+msgid "Email"
msgstr ""
-#: includes/shortcodes/class-abstract-flw-shortcode.php:212
-msgid "Phone Number"
+#: includes/shortcodes/class-abstract-flw-shortcode.php:206
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:86
+msgid "Full name"
msgstr ""
-#: includes/shortcodes/class-abstract-flw-shortcode.php:219
-#: views/donation-payment.php:40
-#: views/donation-payment.php:41
-msgid "First Name"
+#: includes/shortcodes/class-abstract-flw-shortcode.php:213
+msgid "Phone number"
msgstr ""
-#: includes/shortcodes/class-abstract-flw-shortcode.php:226
-#: views/donation-payment.php:47
+#: includes/shortcodes/class-abstract-flw-shortcode.php:220
#: views/donation-payment.php:48
-msgid "Last Name"
+#: views/donation-payment.php:49
+msgid "First name"
+msgstr ""
+
+#: includes/shortcodes/class-abstract-flw-shortcode.php:227
+#: views/donation-payment.php:55
+#: views/donation-payment.php:56
+msgid "Last name"
+msgstr ""
+
+#: includes/shortcodes/class-flw-shortcode-payment-form.php:300
+msgid "Total amount"
msgstr ""
#: includes/vc-elements/class-flw-vc-simple-form.php:37
@@ -201,78 +570,1964 @@ msgstr ""
msgid "view integration"
msgstr ""
-#: views/admin-settings-page.php:26
-msgid "Pay Button Public Key"
+#: views/donation-payment.php:61
+msgid "Payment type"
+msgstr ""
+
+#: views/donation-payment.php:63
+msgid "Give once"
msgstr ""
-#: views/admin-settings-page.php:36
-msgid "Pay Button Secret Key"
+#: views/donation-payment.php:64
+msgid "Give monthly"
msgstr ""
-#: views/admin-settings-page.php:47
-msgid "Secret Hash"
+#: views/donation-payment.php:65
+msgid "Give yearly"
msgstr ""
-#: views/admin-settings-page.php:58
-msgid "Webhook URL"
+#: views/donation-payment.php:73
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:176
+#: client/blocks/edit/donation-form.js:52
+msgid "Suggested amounts"
+msgstr ""
+
+#. translators: %s: Flutterwave, in bold.
+#: views/donation-payment.php:152
+#: views/pay-now-form.php:48
+#, php-format
+msgid "Secured by %s"
+msgstr ""
+
+#: views/donation-payment.php:161
+#: views/pay-now-form.php:58
+msgid "Taking you to Flutterwave to pay. Please don’t close this page."
+msgstr ""
+
+#. translators: 1: current step number, 2: total number of steps.
+#: build/admin.js:2
+#: client/admin/components/index.js:75
+msgid "Step"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:78
+msgid "of"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:297
+msgid "Copied"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:298
+msgid "Copy"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:330
+msgid "Hide"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:331
+msgid "Show"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:348
+msgid "Loading"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/index.js:374
+msgid "Dismiss"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:32
+msgid "Welcome to Flutterwave"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:34
+msgid "for Business"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:38
+msgid "Add payment and donation forms to any page and collect payments in over 30 currencies worldwide, with Cards, Mobile money, Bank transfer, Apple Pay, Google Pay and more."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:45
+msgid "Activate Flutterwave"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:53
+msgid "Documentation"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:55
+msgid "Explore guides and resources to help you set up your payment forms and manage your Flutterwave integration."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:65
+msgid "Read Documentation"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:74
+msgid "Support"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:76
+msgid "Contact our team for dedicated support with your integration."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Welcome.js:86
+msgid "Contact Support"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:8
+#: client/admin/screens/Wizard.js:29
+msgid "General details"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:9
+#: client/admin/screens/Wizard.js:37
+msgid "API & webhook"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:10
+#: client/admin/screens/Wizard.js:48
+#: client/admin/screens/Wizard.js:64
+msgid "Payment methods"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/lib/constants.js:11
+#: client/admin/screens/Settings.js:55
+#: client/admin/screens/Wizard.js:59
+msgid "Redirects"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:36
+msgid "Let customers choose"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:54
+msgid "Nigeria"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:55
+msgid "Ghana"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:56
+msgid "Kenya"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:57
+msgid "South Africa"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:58
+msgid "Tanzania"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:59
+msgid "Uganda"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:60
+msgid "Rwanda"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:61
+msgid "Zambia"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:62
+msgid "Worldwide"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:72
+msgid "Cards (credit/debit cards)"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:73
+msgid "Pay securely using your Visa, Mastercard, or American Express."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:81
+msgid "Pay with USDT or USDC. Fast, global, and low-fee."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:88
+msgid "Bank Transfer"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:89
+msgid "Transfer funds directly from your bank account using your bank's transfer service."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:96
+msgid "Mobile Money"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:97
+msgid "Quick and secure payment directly from your mobile wallet."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:105
+msgid "Check out in seconds using your Apple Pay wallet."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:113
+msgid "Simple and fast checkout using the cards saved to your Google Account."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:120
+msgid "Opay"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:121
+msgid "Pay directly from your OPay wallet or card."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:141
+msgid "Shown at the top of the Flutterwave payment page. Defaults to your site name."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:145
+msgid "Forms charge in this currency unless a shortcode sets its own."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:149
+msgid "Your Flutterwave public key. Use a test key (FLWPUBK_TEST-…) to try payments without real money."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/constants.js:153
+msgid "Your Flutterwave secret key, used to verify payments. It must be from the same mode as the public key."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:67
+msgid "Enter a title customers will see when they pay."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:74
+msgid "Choose the currency your forms charge in."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:81
+#: client/admin/lib/validate.js:160
+msgid "Enter a full URL, starting with https://."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:102
+msgid "Enter your public key."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:104
+msgid "Public keys start with FLWPUBK_TEST- or FLWPUBK-."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:111
+msgid "Enter your secret key."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:118
+msgid "Use a secret key from the same mode (test or live) as your public key."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:113
+msgid "Secret keys start with FLWSECK_TEST- or FLWSECK-."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/lib/validate.js:140
+msgid "Select at least one payment method."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:54
+msgid "Payment page title"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:61
+msgid "Enter your business name"
+msgstr ""
+
+#: build/admin.js:2
+#: build/blocks.js:1
+#: client/admin/screens/forms.js:73
+#: client/blocks/edit/payment-form.js:36
+#: client/blocks/edit/pricing-card.js:59
+msgid "Description"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:78
+msgid "What customers are paying for"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:90
+msgid "Logo URL"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:91
+msgid "Optional. A square image shown on the payment page."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:111
+msgid "Default currency"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:119
+msgid "Select currency"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:131
+msgid "Country"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:137
+msgid "Select country"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:147
+msgid "Pay button text"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:148
+#: client/admin/screens/forms.js:152
+#: client/blocks/edit/payment-form.js:47
+msgid "Proceed to Flutterwave"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:36
+#: client/admin/screens/forms.js:163
+msgid "Donation form"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:166
+msgid "Optional. Shown above the [flw-donation-form] shortcode."
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:133
+#: client/admin/screens/FormBuilder.js:157
+#: client/admin/screens/forms.js:174
+#: client/blocks/edit/donation-form.js:32
+#: client/blocks/edit/payment-form.js:30
+msgid "Heading"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:158
+#: client/admin/screens/forms.js:179
+msgid "Support our work"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:162
+#: client/admin/screens/forms.js:188
+#: client/blocks/edit/donation-form.js:42
+msgid "Message"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:220
+msgid "Live keys: customers will be charged real money."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:224
+msgid "Test keys: payments are simulated, no real money moves."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:233
+msgid "Public key"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:251
+msgid "Secret key"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:267
+msgid "Webhook URL"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:268
+msgid "Add this URL to the webhook settings in your Flutterwave dashboard."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:278
+msgid "Secret hash"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:279
+msgid "Copy this into the secret hash field of your Flutterwave webhook settings. Webhooks are rejected unless the two match."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:301
+msgid "Generate"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:406
+msgid "Advanced"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:418
+msgid "Additional Flutterwave payment options. These are not offered unless selected."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:450
+msgid "Style payment forms with my theme instead of Flutterwave's design"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:475
+msgid "After a successful payment"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:479
+msgid "After a failed payment"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/forms.js:483
+msgid "While a payment is still processing"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Wizard.js:22
+msgid "Please fill in your correct details to set up your payment forms"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Wizard.js:38
+msgid "Find your keys under Settings → API keys in your Flutterwave dashboard."
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:28
+#: client/admin/screens/Settings.js:38
+#: client/admin/screens/Wizard.js:42
+msgid "General"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Wizard.js:49
+msgid "Choose how customers can pay through your forms."
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:37
+#: client/admin/screens/Settings.js:47
+#: client/admin/screens/Wizard.js:53
+msgid "API & Webhook"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Wizard.js:60
+msgid "Where customers land after paying. Your home page works to start with."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Wizard.js:166
+msgid "Go back"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:125
+#: client/admin/screens/Wizard.js:131
+msgid "Could not save your settings. Please try again."
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:60
+#: client/admin/screens/Wizard.js:175
+msgid "Save"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:23
+#: client/admin/screens/Wizard.js:176
+msgid "Save & continue"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Success.js:24
+msgid "Setup successful!"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Success.js:26
+msgid "Your Flutterwave account is connected. Add a payment form to any page with this shortcode:"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Success.js:35
+msgid "Leave out amount to let customers enter their own, or use [flw-donation-form] for donations."
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Success.js:47
+msgid "Create a page"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/screens/Success.js:51
+msgid "Go to settings"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:94
+msgid "Today"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:95
+msgid "Last 7 days"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:96
+msgid "30 days"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:97
+msgid "1 year"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:112
+msgid "Date range"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:146
+msgid "From"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:157
+msgid "To"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:293
+msgid "No matches"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:7
+#: client/admin/components/list.js:304
+#: client/admin/screens/Transactions.js:35
+msgid "Successful"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:7
+#: client/admin/components/list.js:305
+#: client/admin/screens/Transactions.js:36
+msgid "Pending"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:7
+#: client/admin/components/list.js:306
+#: client/admin/screens/Transactions.js:37
+msgid "Failed"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:7
+#: client/admin/components/list.js:307
+#: client/admin/screens/Transactions.js:38
+msgid "Cancelled"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:7
+#: client/admin/components/list.js:308
+#: client/admin/screens/Transactions.js:39
+msgid "Needs review"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:309
+msgid "Published"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:310
+msgid "Draft"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:311
+msgid "Scheduled"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:312
+msgid "Private"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:313
+msgid "Synced pattern"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:349
+msgid "Pages"
+msgstr ""
+
+#: build/admin.js:2
+#: client/admin/components/list.js:357
+msgid "Previous"
+msgstr ""
+
+#: build/admin.js:2
+#: build/admin.js:4
+#: client/admin/components/list.js:365
+#: client/admin/components/tour.js:435
+msgid "Next"
+msgstr ""
+
+#. translators: 1: current page, 2: total pages.
+#: build/admin.js:3
+#: client/admin/components/list.js:370
+#, js-format
+msgid "Page %1$d of %2$d"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/components/list.js:407
+#: client/admin/components/list.js:450
+msgid "Close"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:30
+msgid "Welcome to your Flutterwave dashboard"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:34
+msgid "You’re set up to take payments. Here’s a one-minute look at what you can do next."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:41
+msgid "Your settings"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:42
+msgid "Update your business details, API keys, payment methods and redirects here, one tab at a time."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:51
+msgid "Create payment buttons, forms, donation forms and pricing cards, and see the pages they’re on."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:60
+msgid "Every payment in one place. Filter by date, status and currency, and download a CSV."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:69
+msgid "Take Flutterwave payments in WooCommerce, Easy Digital Downloads and GiveWP."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:76
+msgid "Help is always here"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:77
+msgid "Replay a tour or turn tips off from this menu at any time."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:86
+msgid "Create a form"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:87
+msgid "Pick a payment button, form, donation form or pricing card, preview it, and we’ll put it on a new page for you."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:94
+msgid "Filter by type"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:95
+msgid "Every block and shortcode on your site is listed here, whichever way you added it."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:102
+msgid "See what each form earns"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:103
+msgid "Each row shows the page a form is on, whether it’s live, and the payments it has taken."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:112
+msgid "Find a payment"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:113
+msgid "Narrow the list by date, status or currency. The last year is shown to start with."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:120
+msgid "Download for your records"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:121
+msgid "Export any date range as a CSV file for your spreadsheet or accountant."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:128
+msgid "Open a transaction"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:129
+msgid "Click a row for the full details. You can re-check a pending payment with Flutterwave from there."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:138
+msgid "Connect your plugins"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:139
+msgid "Use Flutterwave in the store or donation plugin you already run. Install, activate or switch on each one from its card."
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:146
+msgid "More on the way"
+msgstr ""
+
+#: build/admin.js:3
+#: client/admin/lib/tour.js:147
+msgid "These are coming next. Tell us which plugin you’d like us to add from the link at the bottom of the page."
+msgstr ""
+
+#. translators: 1: step number, 2: number of steps.
+#: build/admin.js:4
+#: client/admin/components/tour.js:403
+#, js-format
+msgid "%1$d of %2$d"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:423
+msgid "Skip tour"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:429
+#: client/admin/screens/FormBuilder.js:385
+msgid "Back"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:434
+msgid "Done"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:460
+#: client/admin/components/tour.js:466
+#: client/admin/components/tour.js:471
+msgid "Help"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:486
+msgid "Take the tour"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:489
+msgid "A quick walk through this page."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:500
+#: client/admin/components/tour.js:511
+msgid "Show tips"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:503
+msgid "Guide me the first time I open each page."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/components/tour.js:526
+msgid "Flutterwave documentation"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:46
+#: client/admin/screens/Settings.js:56
+msgid "Payment Methods"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/Settings.js:116
+msgid "Changes saved."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/app.js:52
+msgid "Could not load your Flutterwave settings."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:17
+msgid "Payment button"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:18
+msgid "Buttons"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:19
+msgid "A fixed price with a one-line checkout."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:28
+msgid "Payment form"
+msgstr ""
+
+#: build/admin.js:4
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:12
+#: client/admin/screens/form-types.js:29
+msgid "Forms"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:30
+msgid "Invoices and custom amounts."
+msgstr ""
+
+#: build/admin.js:4
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:11
+#: client/admin/screens/form-types.js:37
+msgid "Donations"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:38
+msgid "One-off or recurring gifts."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:44
+msgid "Pricing card"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:45
+#: client/admin/screens/FormBuilder.js:37
+msgid "Pricing"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:46
+msgid "A plan or product with its features."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:55
+msgid "Payment methods badge"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:56
+msgid "Badges"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/form-types.js:57
+msgid "Shows the payment methods you accept."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:34
+msgid "Buy now"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:35
+msgid "Make a payment"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:36
+msgid "Donate"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:56
+msgid "Customer enters the amount"
+msgstr ""
+
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:82
+#: client/blocks/edit/payment-button.js:38
+#: client/blocks/edit/payment-form.js:73
+#: client/blocks/edit/pricing-card.js:42
+msgid "Site default"
+msgstr ""
+
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:123
+#: client/admin/screens/FormBuilder.js:147
+#: client/blocks/components.js:161
+#: client/blocks/edit/payment-button.js:44
+#: client/blocks/edit/payment-form.js:46
+#: client/blocks/edit/pricing-card.js:97
+msgid "Button text"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:134
+msgid "Pay an invoice"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:138
+msgid "Fixed amount (optional)"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:188
+msgid "Plan name"
+msgstr ""
+
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:192
+#: client/blocks/edit/pricing-card.js:36
+msgid "Price"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:197
+msgid "Features (one per line)"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:222
+msgid "Badge (optional)"
+msgstr ""
+
+#: build/admin.js:4
+#: build/blocks.js:1
+#: client/admin/screens/FormBuilder.js:223
+#: client/blocks/edit/pricing-card.js:90
+msgid "Popular"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:270
+msgid "Preview unavailable."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:334
+msgid "New payment form"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:338
+msgid "What would you like to add to your site?"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:326
+msgid "Could not create the page."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:392
+msgid "Create page"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:401
+msgid "Page title"
+msgstr ""
+
+#: build/admin.js:4
+#: build/admin.js:5
+#: client/admin/screens/FormBuilder.js:418
+#: client/admin/screens/PaymentForms.js:300
+msgid "Shortcode"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:421
+msgid "Or paste this into any page, post or widget."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:425
+msgid "Pricing cards are available as a block only."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/FormBuilder.js:441
+msgid "Preview"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/PaymentForms.js:18
+msgid "All forms"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/PaymentForms.js:45
+msgid "Customer enters"
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/PaymentForms.js:115
+msgid "Could not load your payment forms."
+msgstr ""
+
+#: build/admin.js:4
+#: client/admin/screens/PaymentForms.js:140
+msgid "New form"
+msgstr ""
+
+#. translators: %d: number of payment forms.
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:180
+#, js-format
+msgid "%d Payment form"
+msgid_plural "%d Payment forms"
+msgstr[0] ""
+msgstr[1] ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:193
+msgid "View transactions"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:216
+msgid "Create your first payment form"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:222
+msgid "Payment buttons, forms, donation forms and pricing cards you add to your pages will be listed here with the payments they collect."
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:237
+msgid "Form"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:243
+msgid "Used on"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:249
+msgid "Payments"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:253
+msgid "Actions"
+msgstr ""
+
+#: build/admin.js:5
+#: client/admin/screens/PaymentForms.js:296
+msgid "Block"
+msgstr ""
+
+#. translators: %s: date the page was last changed.
+#: build/admin.js:6
+#: client/admin/screens/PaymentForms.js:316
+#, js-format
+msgid "Updated %s"
+msgstr ""
+
+#. translators: %d: number of successful payments.
+#: build/admin.js:7
+#: client/admin/screens/PaymentForms.js:77
+#, js-format
+msgid "%d payment"
+msgid_plural "%d payments"
+msgstr[0] ""
+msgstr[1] ""
+
+#: build/admin.js:7
+#: client/admin/screens/PaymentForms.js:340
+msgid "Edit"
+msgstr ""
+
+#: build/admin.js:7
+#: client/admin/screens/PaymentForms.js:353
+msgid "View"
+msgstr ""
+
+#: build/admin.js:7
+#: client/admin/screens/PaymentForms.js:369
+msgid "No forms of this type yet."
+msgstr ""
+
+#: build/admin.js:7
+#: client/admin/screens/PaymentForms.js:380
+msgid "Payments are counted per page for payments made since this version of the plugin. Shortcodes and blocks both appear here."
+msgstr ""
+
+#: build/admin.js:7
+#: client/admin/screens/Transactions.js:85
+msgid "Filter transactions"
+msgstr ""
+
+#. translators: %d: number of filters in effect.
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:95
+#, js-format
+msgid "Filter applied: %d"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:108
+#: client/admin/screens/Transactions.js:218
+msgid "Custom date range"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:118
+msgid "Date range cannot exceed the last 1 year"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:127
+msgid "Select items"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:137
+msgid "Enter or select items"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:156
+msgid "Clear"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:167
+msgid "Apply filter"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:199
+msgid "Download transactions"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:207
+msgid "Custom Download"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:226
+msgid "Downloads a CSV file you can open in any spreadsheet."
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:234
+msgid "Cancel"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:237
+msgid "Download"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:265
+msgid "Status updated from Flutterwave."
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:275
+msgid "Transaction deleted."
+msgstr ""
+
+#: build/admin.js:8
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:256
+#: client/admin/screens/Transactions.js:282
+msgid "Something went wrong. Please try again."
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:294
+msgid "Transaction details"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:304
+msgid "Re-verify with Flutterwave"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:319
+msgid "Delete this transaction record? This cannot be undone."
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:329
+msgid "Delete record"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:339
+msgid "Reference"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:352
+msgid "Status detail"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:357
+msgid "Flutterwave ID"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:361
+msgid "Paid on"
+msgstr ""
+
+#: build/admin.js:8
+#: client/admin/screens/Transactions.js:407
+msgid "Could not load transactions."
+msgstr ""
+
+#. translators: %d: number of transactions.
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:446
+#, js-format
+msgid "%d Transaction"
+msgid_plural "%d Transactions"
+msgstr[0] ""
+msgstr[1] ""
+
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:506
+msgid "Unknown customer"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:546
+msgid "No transactions found"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:553
+msgid "No payments match these filters."
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:557
+msgid "Payments made through your forms in this period will show up here."
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/screens/Transactions.js:567
+msgid "Clear filters"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:9
+msgid "E-commerce"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:10
+msgid "Digital products"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:13
+msgid "Memberships"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:14
+msgid "Courses"
+msgstr ""
+
+#: build/admin.js:9
+#: client/admin/lib/integrations.js:15
+msgid "Events"
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:10
+#: client/admin/lib/integrations.js:39
+#, js-format
+msgid "Activate %s"
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:11
+#: client/admin/lib/integrations.js:47
+#, js-format
+msgid "Ask an administrator to activate %s."
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:12
+#: client/admin/lib/integrations.js:62
+#, js-format
+msgid "Install %s"
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:70
+#, js-format
+msgid "%s is not installed."
+msgstr ""
+
+#: build/admin.js:13
+#: build/admin.js:19
+#: client/admin/lib/integrations.js:88
+#: client/admin/lib/integrations.js:126
+#: client/admin/screens/Integrations.js:412
+msgid "Coming soon"
+msgstr ""
+
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:113
+msgid "Manage"
+msgstr ""
+
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:109
+msgid "Enable"
+msgstr ""
+
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:131
+msgid "Active"
+msgstr ""
+
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:137
+msgid "Not installed"
+msgstr ""
+
+#: build/admin.js:13
+#: client/admin/lib/integrations.js:143
+msgid "Ready to connect"
+msgstr ""
+
+#. translators: %s: extension name.
+#: build/admin.js:14
+#: client/admin/screens/Integrations.js:117
+#, js-format
+msgid "Uses the free %s extension."
+msgstr ""
+
+#. translators: 1: currency code, 2: plugin name.
+#: build/admin.js:15
+#: client/admin/screens/Integrations.js:130
+#, js-format
+msgid "%1$s, the currency set in %2$s, is not supported by Flutterwave."
+msgstr ""
+
+#: build/admin.js:15
+#: client/admin/screens/Integrations.js:175
+msgid "Gateway settings"
+msgstr ""
+
+#: build/admin.js:15
+#: client/admin/screens/Integrations.js:179
+msgid "Manage settings"
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:191
+#, js-format
+msgid "Flutterwave for %s"
+msgstr ""
+
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:211
+msgid "All"
+msgstr ""
+
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:232
+msgid "Could not load integrations."
+msgstr ""
+
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:274
+msgid "Plugin installed and activated."
+msgstr ""
+
+#: build/admin.js:16
+#: client/admin/screens/Integrations.js:275
+msgid "Plugin activated."
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:17
+#: client/admin/screens/Integrations.js:285
+#, js-format
+msgid "Flutterwave is now a payment option in %s."
+msgstr ""
+
+#. translators: %s: plugin name.
+#: build/admin.js:18
+#: client/admin/screens/Integrations.js:293
+#, js-format
+msgid "Flutterwave is no longer offered in %s."
+msgstr ""
+
+#: build/admin.js:18
+#: client/admin/screens/Integrations.js:343
+msgid "Take Flutterwave payments inside the plugins you already use to sell products, collect donations and run memberships."
+msgstr ""
+
+#. translators: %d: number of connected integrations.
+#: build/admin.js:19
+#: client/admin/screens/Integrations.js:353
+#, js-format
+msgid "%d connected"
+msgstr ""
+
+#: build/admin.js:19
+#: client/admin/screens/Integrations.js:363
+msgid "Category"
+msgstr ""
+
+#: build/admin.js:19
+#: client/admin/screens/Integrations.js:395
+msgid "Available"
+msgstr ""
+
+#: build/admin.js:19
+#: client/admin/screens/Integrations.js:421
+msgid "Using another plugin to sell or take donations?"
+msgstr ""
+
+#: build/admin.js:19
+#: client/admin/screens/Integrations.js:430
+msgid "Tell us which one"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/components.js:70
+msgid "Connect your Flutterwave account before publishing: visitors will not see this block until you do."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/components.js:149
+msgid "Flutterwave colours"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/components.js:155
+msgid "Button & accent"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/components.js:165
+msgid "Corners"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/components.js:169
+msgid "Corner radius"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:29
+#: client/blocks/edit/payment-form.js:64
+msgid "Leave empty to let customers enter the amount."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:46
+msgid "{amount} is replaced with the price, e.g. \"Pay NGN 5,000\"."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:56
+msgid "Checkout"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:59
+msgid "Ask for the customer’s name"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:70
+msgid "Use the logged-in user’s email"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:74
+msgid "Logged-in visitors only see the button."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:86
+msgid "Width"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:91
+msgid "Fit content"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:95
+msgid "Full width"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-button.js:102
+#: client/blocks/edit/payment-form.js:117
+msgid "Show “Secured by Flutterwave”"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:28
+#: client/blocks/edit/payment-form.js:26
+msgid "Content"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:51
+msgid "Use {amount} to show the price."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:63
+msgid "Fixed amount"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:77
+msgid "Fields"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:81
+msgid "Customer name"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:90
+msgid "First and last name"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:97
+msgid "Don’t ask"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-form.js:106
+msgid "Ask for a phone number"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:33
+msgid "Leave empty to use the heading from Flutterwave settings."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:48
+msgid "Donation"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:58
+msgid "Shown as"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:59
+msgid "Comma separated, e.g. 1000, 5000, 10000. Up to six."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:69
+msgid "Let donors choose"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/donation-form.js:79
+msgid "Offer monthly and yearly giving"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:27
+msgid "Plan"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:31
+msgid "Name"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:48
+msgid "Price note"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:49
+msgid "one-off"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:50
+msgid "Shown next to the price. Customers pay once."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:68
+msgid "Features"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:69
+msgid "One per line."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:77
+msgid "Highlight"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:80
+msgid "Highlight this plan"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/pricing-card.js:89
+msgid "Badge"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-methods.js:21
+msgid "Label"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-methods.js:27
+msgid "Show “Payments secured by Flutterwave”"
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-methods.js:37
+msgid "The methods shown are the ones enabled in your Flutterwave settings, so this badge stays in step with checkout."
+msgstr ""
+
+#: build/blocks.js:1
+#: client/blocks/edit/payment-methods.js:43
+msgid "Change payment methods"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block title"
+msgid "Flutterwave Donation Form"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block description"
+msgid "Collect one-off or recurring donations, with suggested amounts."
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block keyword"
+msgid "donate"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block keyword"
+msgid "donation"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block keyword"
+msgid "charity"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block keyword"
+msgid "give"
+msgstr ""
+
+#: blocks/donation-form/block.json
+msgctxt "block keyword"
+msgid "recurring"
+msgstr ""
+
+#: blocks/donation-form/block.json
+#: blocks/payment-button/block.json
+#: blocks/payment-form/block.json
+#: blocks/payment-methods/block.json
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "flutterwave"
+msgstr ""
+
+#: blocks/payment-button/block.json
+msgctxt "block title"
+msgid "Flutterwave Payment Button"
+msgstr ""
+
+#: blocks/payment-button/block.json
+msgctxt "block description"
+msgid "A one-line checkout for a fixed amount: an email field and a pay button."
+msgstr ""
+
+#: blocks/payment-button/block.json
+#: blocks/payment-form/block.json
+msgctxt "block keyword"
+msgid "pay"
+msgstr ""
+
+#: blocks/payment-button/block.json
+msgctxt "block keyword"
+msgid "button"
+msgstr ""
+
+#: blocks/payment-button/block.json
+#: blocks/payment-form/block.json
+msgctxt "block keyword"
+msgid "checkout"
+msgstr ""
+
+#: blocks/payment-button/block.json
+msgctxt "block keyword"
+msgid "buy"
+msgstr ""
+
+#: blocks/payment-form/block.json
+msgctxt "block title"
+msgid "Flutterwave Payment Form"
+msgstr ""
+
+#: blocks/payment-form/block.json
+msgctxt "block description"
+msgid "A full payment form with a fixed or customer-entered amount."
+msgstr ""
+
+#: blocks/payment-form/block.json
+msgctxt "block keyword"
+msgid "form"
msgstr ""
-#: views/admin-settings-page.php:62
-msgid "Please copy this webhook URL and paste on the webhook section on your dashboard"
+#: blocks/payment-form/block.json
+msgctxt "block keyword"
+msgid "invoice"
msgstr ""
-#: views/admin-settings-page.php:70
-msgid "Go Live"
+#: blocks/payment-methods/block.json
+msgctxt "block title"
+msgid "Flutterwave Payment Methods"
msgstr ""
-#: views/admin-settings-page.php:77
-msgid "Switch to live account"
+#: blocks/payment-methods/block.json
+msgctxt "block description"
+msgid "Shows the payment methods your forms accept, taken from your Flutterwave settings."
msgstr ""
-#: views/admin-settings-page.php:85
-msgid "Payment Method"
+#: blocks/payment-methods/block.json
+msgctxt "block keyword"
+msgid "accepted"
msgstr ""
-#: views/admin-settings-page.php:102
-msgid "Modal Title"
+#: blocks/payment-methods/block.json
+msgctxt "block keyword"
+msgid "cards"
msgstr ""
-#: views/admin-settings-page.php:112
-msgid "Modal Description"
+#: blocks/payment-methods/block.json
+msgctxt "block keyword"
+msgid "badge"
msgstr ""
-#: views/admin-settings-page.php:122
-msgid "Modal Logo"
+#: blocks/payment-methods/block.json
+msgctxt "block keyword"
+msgid "trust"
msgstr ""
-#: views/admin-settings-page.php:132
-msgid "Donation Title"
+#: blocks/payment-methods/block.json
+msgctxt "block keyword"
+msgid "secure"
msgstr ""
-#: views/admin-settings-page.php:142
-msgid "Donation Description"
+#: blocks/pricing-card/block.json
+msgctxt "block title"
+msgid "Flutterwave Pricing Card"
msgstr ""
-#: views/admin-settings-page.php:152
-msgid "Donation Phone"
+#: blocks/pricing-card/block.json
+msgctxt "block description"
+msgid "A plan or product card with a price, features and checkout."
msgstr ""
-#: views/admin-settings-page.php:162
-msgid "Pending Redirect URL"
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "price"
msgstr ""
-#: views/admin-settings-page.php:172
-msgid "Success Redirect URL"
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "plan"
msgstr ""
-#: views/admin-settings-page.php:182
-msgid "Failed Redirect URL"
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "product"
msgstr ""
-#: views/admin-settings-page.php:192
-msgid "Donation Plan Id"
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "pricing"
msgstr ""
-#: views/donation-payment.php:52
-msgid "Payment Type"
+#: blocks/pricing-card/block.json
+msgctxt "block keyword"
+msgid "tier"
msgstr ""
diff --git a/includes/admin/class-flw-forms-controller.php b/includes/admin/class-flw-forms-controller.php
new file mode 100644
index 0000000..c10fcc9
--- /dev/null
+++ b/includes/admin/class-flw-forms-controller.php
@@ -0,0 +1,389 @@
+
+ */
+ const SHORTCODES = array(
+ 'flw-pay-form' => 'payment-form',
+ 'flw-pay-button' => 'payment-form',
+ 'flw-donation-form' => 'donation-form',
+ );
+
+ /**
+ * Most posts scanned for forms.
+ */
+ const SCAN_LIMIT = 200;
+
+ /**
+ * Register hooks.
+ *
+ * @return void
+ */
+ public static function register_hooks(): void {
+ add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
+ }
+
+ /**
+ * Register the routes.
+ *
+ * @return void
+ */
+ public static function register_routes(): void {
+ register_rest_route(
+ FLW_Settings::REST_NAMESPACE,
+ '/forms',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( __CLASS__, 'list_forms' ),
+ 'permission_callback' => array( __CLASS__, 'can_manage' ),
+ )
+ );
+
+ register_rest_route(
+ FLW_Settings::REST_NAMESPACE,
+ '/forms/page',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( __CLASS__, 'create_page' ),
+ 'permission_callback' => array( __CLASS__, 'can_create_pages' ),
+ )
+ );
+ }
+
+ /**
+ * Only administrators see the forms screen.
+ *
+ * @return bool
+ */
+ public static function can_manage(): bool {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Creating a page also needs the right to edit pages.
+ *
+ * @return bool
+ */
+ public static function can_create_pages(): bool {
+ return current_user_can( 'manage_options' ) && current_user_can( 'edit_pages' );
+ }
+
+ /**
+ * The Flutterwave forms in a piece of content.
+ *
+ * @param string $content Post content.
+ *
+ * @return array[] Each with kind ("block" or "shortcode"), type and attributes.
+ */
+ public static function parse_content( string $content ): array {
+ $forms = array();
+
+ if ( false !== strpos( $content, '
-
-
-
- */vendor/*
-
-
- */node_modules/*
-
-
- *.min.js
- *.js
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- tests/
-
-
-
- */tests/*
- */src/*
-
-
-
- */src/*
- */tests/*
-
-
-
- */src/*
- tests/*
-
-
-
- src/*
- tests/php
- patterns
-
-
-
- src/*
- tests/php
-
-
-
- patterns
-
-
-
- tests/
-
-
-
-
-
-
-
-
- src/*
-
-
-
\ No newline at end of file
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..d378871
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,68 @@
+
+
+ Coding standards for Flutterwave Payments.
+
+ .
+
+ /vendor/*
+ /node_modules/*
+ /build/*
+ /assets/*
+ /.docker/*
+ /bin/*
+ /coverage/*
+ /playwright-report/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /tests/*
+
+
+
+
+ /views/*
+ /blocks/*/render.php
+
+
+
+
+ /src/*
+ /tests/*
+
+
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+ /tests/*
+
+
diff --git a/phpunit-integration.xml.dist b/phpunit-integration.xml.dist
new file mode 100644
index 0000000..beab9c1
--- /dev/null
+++ b/phpunit-integration.xml.dist
@@ -0,0 +1,21 @@
+
+
+
+
+ ./tests/Integration
+
+
+
+
+
+
diff --git a/phpunit.xml b/phpunit.xml
deleted file mode 100644
index 884eba7..0000000
--- a/phpunit.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
- ./includes
- ./rave-payment-forms.php
-
-
-
-
- ./tests/PHPUnit
-
-
-
\ No newline at end of file
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..ccbe5cc
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,25 @@
+
+
+
+
+ ./tests/Unit
+
+
+
+
+ ./includes
+ ./src
+
+
+
diff --git a/playwright.config.js b/playwright.config.js
index 3a471e9..f86755f 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -1,13 +1,38 @@
-require('dotenv').config({ path: '.env.e2e' });
+/**
+ * External dependencies
+ */
+const { defineConfig, devices } = require( '@playwright/test' );
-const config = {
+/**
+ * End-to-end tests run against the wp-env development site (`npm run env:start`).
+ */
+module.exports = defineConfig( {
testDir: './tests/e2e',
- timeout: 60000,
+ // Only specs: tests/e2e/report holds unit tests for the report script, run by Vitest.
+ testMatch: '**/*.spec.js',
+ globalSetup: require.resolve( './tests/e2e/global-setup.js' ),
+ timeout: 60_000,
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: !! process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ // Every run writes an HTML report (playwright-report/) plus JSON and JUnit
+ // results (test-reports/) that the scheduled report job summarises.
+ reporter: [
+ process.env.CI ? [ 'github' ] : [ 'list' ],
+ [ 'html', { open: 'never', outputFolder: 'playwright-report' } ],
+ [ 'json', { outputFile: 'test-reports/e2e-results.json' } ],
+ [ 'junit', { outputFile: 'test-reports/e2e-junit.xml' } ],
+ ],
use: {
- baseURL: process.env.BASEURL,
- ignoreHTTPSErrors: true,
+ baseURL: process.env.WP_BASE_URL || 'http://localhost:8888',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
},
- // workers: process.env.CI || process.env.GITPOD_MEMORY ? 1 : undefined,
-};
-
-module.exports = config;
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices[ 'Desktop Chrome' ] },
+ },
+ ],
+} );
diff --git a/rave-payment-forms.php b/rave-payment-forms.php
index 0fc8e9e..667fef8 100644
--- a/rave-payment-forms.php
+++ b/rave-payment-forms.php
@@ -3,8 +3,8 @@
* Plugin Name: Flutterwave Payments
* Plugin URI: http://flutterwave.com/
* Description: Flutterwave payment gateway forms, accept local and international payments securely.
- * Version: 1.0.7
- * Requires at least: 5.2
+ * Version: 1.1.0
+ * Requires at least: 6.4
* Requires PHP: 7.4
* Author: Flutterwave Developers
* Author URI: https://developer.flutterwave.com/
@@ -12,8 +12,6 @@
* License: MIT License
* Text Domain: rave-payment-forms
* Domain Path: i18n/languages
- * Requires at least: 5.6
- * Requires PHP: 7.4
*
* @package Flutterwave Payments
**/
@@ -30,4 +28,3 @@
require_once dirname( FLW_PAY_PLUGIN_FILE ) . '/includes/class-flutterwave-payments.php';
$flw_pay_class = Flutterwave_Payments::get_instance();
}
-
diff --git a/readme.md b/readme.md
index 0f79f5d..060a2d0 100644
--- a/readme.md
+++ b/readme.md
@@ -2,187 +2,228 @@
-# Flutterwave Payments
+# Flutterwave Payments for WordPress
+
+The official Flutterwave plugin for WordPress. Add a payment button, payment form, donation form or pricing card to any page. Customers can pay with cards, bank transfer, mobile money, Apple Pay, Google Pay and more.
+
+You don't need an online store, a developer or any code. A guided setup takes a few minutes, and you can try everything with test keys before taking real money.
+
+## Table of contents
+
+1. [Features](#features)
+2. [Requirements](#requirements)
+3. [Installation and setup](#installation-and-setup)
+4. [Blocks](#blocks)
+5. [Shortcodes](#shortcodes)
+6. [Integrations](#integrations)
+7. [Payments and reporting](#payments-and-reporting)
+8. [Security](#security)
+9. [External services](#external-services)
+10. [Troubleshooting](#troubleshooting)
+11. [Support](#support)
+12. [Development](#development)
+13. [License](#license)
+
+## Features
+
+- **Payment methods:** cards (Visa, Mastercard, American Express), bank transfer, mobile money, Apple Pay, Google Pay, Opay and stablecoins (USDT and USDC). USSD, QR, NQR, Barter and Credit are available under Advanced.
+- **Currencies:** NGN, GHS, KES, ZAR, TZS, UGX, RWF, ZMW, USD, GBP and EUR. You can also let customers choose.
+- **Guided setup:** business details, API keys, webhook, payment methods and redirects. It generates your webhook secret and tells you whether you're using test or live keys.
+- **Five blocks:** payment button, payment form, donation form, pricing card and accepted payment methods, each with its own colours and corner radius.
+- **Recurring donations:** donors can give once, monthly or yearly, and you can suggest amounts.
+- **Payment Forms screen:** every form on your site, the page it's on and the payments it has taken, plus a builder with a live preview.
+- **Transactions screen:** filter by date, status and currency, check a payment again with Flutterwave, and download a CSV.
+- **Integrations:** Easy Digital Downloads and GiveWP gateways, and one-click install of the Flutterwave WooCommerce extension.
-## Introduction
-
-The WordPress Plugin makes it very easy and quick to add Flutterwave Payment options on your eCommerce site, Donation Page or a list of Payment Subscriptions you want your clients to subscribe to.
-
-Take donations and payments for services on your WordPress site using Flutterwave.
+## Requirements
+- A [Flutterwave account](https://app.flutterwave.com/register) and its [API keys](https://developer.flutterwave.com/docs/authentication).
+- WordPress 6.4 or later.
+- PHP 7.4 or later.
-## Description
+## Installation and setup
+1. In your WordPress dashboard, go to **Plugins > Add New**, search for **Flutterwave Payments**, then click **Install Now** and **Activate**.
+ To install manually, download the zip and use **Plugins > Add New > Upload Plugin**.
+2. Go to **Flutterwave > Settings** and click **Activate Flutterwave** to start the guided setup.
+3. **General details:** add your business name, logo and default currency. These appear on the Flutterwave checkout.
+4. **API & webhook:** paste your public and secret keys from **Settings > API Keys** in your Flutterwave dashboard. Then copy the webhook URL and secret hash from the plugin into **Settings > Webhooks** in the Flutterwave dashboard.
+5. **Payment methods:** choose the methods customers can use. You can also turn on your theme's form styles here.
+6. **Redirects:** pick the pages customers see after a successful, failed or pending payment. All three are required.
-Available features include:
+Then add a Flutterwave block or shortcode to any page. Use test keys (`FLWPUBK_TEST-…`) and Flutterwave's [test cards](https://developer.flutterwave.com/docs/testing) to try it, and switch to live keys when you're ready.
-- Collections: Card, Account, Mobile money, Bank Transfers, USSD, Barter, NQR.
-- Recurring payments: Tokenization and Subscriptions.
-- Split payments: Split payments between multiple recipients.
+You can change any setting later from the **General**, **API & Webhook**, **Payment Methods** and **Redirects** tabs.
-## Table of Contents
+> A form only appears once your API keys and all three redirect pages are saved. Until then, editors see a notice with a link to the missing setting, and visitors see nothing.
-1. [Requirements](#requirements)
-2. [Installation](#installation)
-3. [Initialization](#initialization)
-4. [Best Practices](#best-practices)
-5. [Debugging Errors](#debugging-errors)
-6. [Support](#support)
-7. [Contribution guidelines](#contribution-guidelines)
-9. [License](#)
-10. [Changelog](#)
+## Blocks
+In the block editor, open the inserter and choose the **Flutterwave** category.
-## Requirements
+| Block | What it does |
+| --- | --- |
+| Payment Button | An email field and a pay button for a fixed amount, like "Pay NGN 5,000". Logged-in users can skip the email. |
+| Payment Form | A full form with an optional heading and description, and a fixed amount or one the customer enters. Name and phone fields are optional. |
+| Donation Form | One-off, monthly or yearly donations, with up to six suggested amounts. |
+| Pricing Card | A plan or product card with a price, features, an optional badge and a checkout button. |
+| Payment Methods | A badge listing the methods enabled in **Flutterwave > Settings**. |
-1. Flutterwave for business [API Keys](https://developer.flutterwave.com/docs/integration-guides/authentication)
-2. Supported PHP version: 7.4 or higher
-3. For Elementor: Elementor version: 2.8.0 or higher
+Each block has its own accent colour, button text colour and corner radius under the block's **Styles** tab. Button text can include `{amount}`, which is replaced with the formatted price. Blocks are rendered on the server with the same code as the shortcodes.
-## Installation
+## Shortcodes
-### Automatic Installation
-* Login to your WordPress Dashboard.
-* Click on "Plugins > Add New" from the left menu.
-* In the search box type __Flutterwave Payments__.
-* Click on __Install Now__ on __Flutterwave Payments__ to install the plugin on your site.
-* Confirm the installation.
-* Activate the plugin.
-* Go to "Rave > Settings" from the left menu to configure the plugin.
+Paste a shortcode into any page, post or widget, or use it in a page builder. **Flutterwave > Payment Forms** has a builder that writes the shortcode for you.
+### Payment form
-### Manual Installation
-* Download the plugin zip file.
-* Login to your WordPress Admin. Click on "Plugins > Add New" from the left menu.
-* Click on the "Upload" option, then click "Choose File" to select the zip file you downloaded. Click "OK" and "Install Now" to complete the installation.
-* Activate the plugin.
-* Go to "Rave > Settings" from the left menu to configure the plugin.
+```
+[flw-pay-form]
+```
-For FTP manual installation, [check here](http://codex.wordpress.org/Managing_Plugins#Manual_Plugin_Installation).
+| Attribute | Example | What it does |
+| --- | --- | --- |
+| `amount` | `amount="5000"` | Fixed amount. Leave it out to let the customer enter one. |
+| `currency` | `currency="NGN,USD,GBP"` | One currency, or several for the customer to choose from. Defaults to the currency in settings. |
+| `heading`, `description` | `heading="Book a session"` | Text above the form. |
+| `email` | `email="customer@example.com"` | Pre-fills the email. |
+| `use_current_user_email` | `use_current_user_email="yes"` | Fills in the logged-in user's email. |
+| `split_name` | `split_name="1"` | Separate first and last name fields. |
+| `exclude` | `exclude="phone"` | Hides a field (`phone`, `fullname` or a custom field). |
+| `custom_fields` | `custom_fields="age:number,size:select\|Small:S\|Large:L"` | Adds your own fields. |
+| `order` | `order="email,fullname,amount,currency"` | Changes the field order. |
+| `layout` | `layout="compact"` | No card around the form; fields and button sit on one line, like the Payment Button block. |
+| `width` | `width="full"` | Fills the width of its container. |
+| `show_secured` | `show_secured="0"` | Hides the "Secured by Flutterwave" line. |
+
+Button text goes between the tags:
-## Best Practices
+```
+[flw-pay-form amount="5000" currency="NGN"]Pay {amount}[/flw-pay-form]
+```
-- When in doubt about a transaction, always check the Flutterwave Dashboard to confirm the status of a transaction.
-- Always ensure you keep your API keys securely and privately. Do not share with anyone.
-- Ensure you change from the default secret hash on the Wordpress admin and apply same on the Flutterwave Dashboard.
-- Always ensure you install the most recent version of the Flutterwave WooCommerce plugin.
+More examples:
-## Debugging Errors
+```
+[flw-pay-form amount="1234" currency="USD,UGX,NGN" custom_fields="age:number,color:select|black:#000|white:#fff" exclude="phone"]
-We understand that you may run into some errors while integrating our plugin. You can read more about our error messages [here](https://developer.flutterwave.com/docs/integration-guides/errors).
+[flw-pay-form amount="1234" currency="USD,UGX,NGN" order="currency,fullname,amount,phone,email"]
+```
-For `authorization` and `validation` error responses, double-check your API keys and request. If you get a `server` error, kindly engage the team for support.
+`[flw-pay-button]` from earlier versions still works and accepts the same attributes.
-## Support
+### Donation form
-For additional assistance using this library, contact the developer experience (DX) team via [email](mailto:developers@flutterwavego.com) or on [slack](https://bit.ly/34Vkzcg).
+```
+[flw-donation-form]
+```
-You can also follow us [@FlutterwaveEng](https://twitter.com/FlutterwaveEng) and let us know what you think 😊.
+| Attribute | Example | What it does |
+| --- | --- | --- |
+| `heading`, `message` | `heading="Support our work"` | Text above the form. |
+| `currency` | `currency="USD"` | The donation currency. |
+| `amounts` | `amounts="1000,5000,10000"` | Up to six suggested amounts. |
+| `show_frequency` | `show_frequency="0"` | One-off donations only. |
-### Configure the plugin
-To configure the plugin, go to __Rave > Settings__ from the left menu.
+Monthly and yearly donations use a Flutterwave payment plan, which the plugin creates for you.
-###
-
+### Classic Editor and WPBakery
-* __Pay Button Public Key__ - Enter your public key which can be retrieved from "Pay Buttons" page on your Rave account dashboard.
-* __Recurring Payments__ - To enable recurring payments/subscription for your users, click the Enable Recurring Payment 'checkbox' to enable it. Go to your Rave Dashboard, navigate to 'Payment Plans' and click the 'Create Payment Plan' button to create a payment plan with an interval. The intervals accepted for this plugin are WEEKLY, MONTHLy, QUARTERLY, ANNUALLY/YEARLY.
-* __Modal Title__ - (Optional) customize the title of the Pay Modal. Default is FLW PAY.
-* __Modal Description__ - (Optional) customize the description on the Pay Modal. Default is FLW PAY MODAL.
-* __Modal Logo__ - (Optional) customize the logo on the Pay Modal. Enter a full url (with 'http'). Default is Rave logo.
-* __Success Redirect URL__ - (Optional) The URL the user should be redirected to after a successful payment. Enter a full url (with 'http'). Default: "".
-* __Failed Redirect URL__ - (Optional) The URL the user should be redirected to after a failed payment. Enter a full url (with 'http'). Default: "".
-* __Pay Button Text__ - (Optional) The text to display on the button. Default: "PAY NOW".
-* __Charge Currency__ - (Optional) The currency the user is charged. Default: "NGN".
-* __Charge Country__ - (Optional) The country the merchant is serving. Default: "NG: Nigeria".
-* __Form Style__ - (Optional) Disable form default style and use the activated theme style instead.
-* Click __Save Changes__ to save your changes.
+The Classic Editor has a Flutterwave toolbar button that inserts a payment shortcode. WPBakery Page Builder has a **Flutterwave Simple Form** element.
### Styling
-You can enable default theme's style to override default form style from the __Settings__ page.
-Or you can override the _form_ class `.flw-simple-pay-now-form` from your stylesheet.
+Turn on the theme style option under **Payment Methods** in the settings to use your theme's form styles, or target `.flw-simple-pay-now-form` in your own CSS.
-## Usage ##
+## Integrations
-####1. Shortcode
+Go to **Flutterwave > Integrations**.
-Insert the shortcode anywhere on your page or post that you want the form to be displayed to the user.
+| Plugin | What you get |
+| --- | --- |
+| Easy Digital Downloads | Sell ebooks, software, music and other downloads, paid with Flutterwave at checkout. |
+| GiveWP | Collect donations through GiveWP forms and campaigns. |
+| WooCommerce | Install and activate the official Flutterwave WooCommerce extension in one click. |
-Basic: _requires the user to enter amount and email to complete payment_
-```
-[flw-pay-button]
-```
+Easy Digital Downloads and GiveWP payments use Flutterwave's hosted checkout. They're confirmed when the customer returns and again by webhook.
-With button text:
-```
-[flw-pay-button]Button Text[/flw-pay-button]
-```
+Coming soon: WPForms, Gravity Forms, Contact Form 7, Paid Memberships Pro, MemberPress, LearnDash, Tutor LMS and Event Tickets.
-With attributes: _email_ or _use_current_user_email_ with value "yes", _amount_
-```
-[flw-pay-button amount="1290" email="customer@email.com" ]
+## Payments and reporting
-or
+- **Flutterwave > Transactions** lists every payment made through your forms. Filter by date range, status and currency, open a payment to check it again with Flutterwave or delete it, and download a CSV for a date range. The CSV is safe to open in a spreadsheet: values that could run as formulas are neutralised.
+- **Flutterwave > Payment Forms** shows each form, the page it's on, whether the page is published, and how many successful payments it has taken in each currency.
+- When in doubt about a payment, check your Flutterwave dashboard. It is always the final record.
-[flw-pay-button amount="1290" use_current_user_email="yes" ]
-```
+## Security
-With attributes and button text: _email_, _amount_
-```
-[flw-pay-button amount="1290" email="customer@email.com" ]Button Text[/flw-pay-button]
+- Customers enter payment details on Flutterwave's checkout, never on your site.
+- The amount and currency of each form are signed on your server, so they can't be changed in the browser.
+- Every payment is verified with Flutterwave against the stored transaction reference, amount and currency before it's marked successful.
+- Webhooks are rejected unless a secret hash is configured, and the hash is compared in constant time.
+- Secret keys are masked in the admin, settings are validated, and checkout requests are rate-limited (see the `flw_checkout_rate_limit` filter).
+- Keep your API keys private, and use your own secret hash rather than sharing it.
-or
+## External services
-[flw-pay-button amount="1290" email="customer@email.com" split_name=1 ]Button Text[/flw-pay-button]
+The plugin connects to services run by Flutterwave Technology Solutions.
+**Flutterwave payments API** (`api.flutterwave.com`) processes payments. When a customer submits a form, the plugin sends the amount, currency, the customer's email, name and phone number, and a transaction reference. It then asks the API to verify the payment when the customer returns or a webhook arrives. It also reads your account's transactions for the Transactions screen.
-```
+**Flutterwave integration analytics** (`signozservice-prod.f4b-flutterwave.com`) helps Flutterwave see whether integrations are working. The plugin sends:
-With custom and excluded fields.
-```
-[flw-pay-form amount=1234 fullname="Abraham Olaobaju" currency="USD,UGX,NGN" custom_fields='age:number,color:select|black:#000|white:#fff' exclude="phone"]
+- when your site is first set up: your public API key, the plugin name and version;
+- when a payment starts: the transaction reference, whether you use test or live keys, and the plugin version;
+- when a live payment succeeds: the transaction reference, amount, currency, fee and payment method;
+- when something goes wrong: an error code and message, with the transaction reference where there is one.
-```
+No customer names, email addresses, phone numbers or card details are sent to the analytics service. To turn it off, add `define( 'FLW_DISABLE_TELEMETRY', true );` to `wp-config.php`, or return `false` from the `flw_signoz_enabled` filter.
-With order rearranged.
-```
-[flw-pay-form amount=1234 fullname="Abraham Olaobaju" currency="USD,UGX,NGN" order="currency,fullname,amount,phone,email"]
-```
+Both services are covered by Flutterwave's [Terms of Service](https://flutterwave.com/us/terms) and [Privacy Notice](https://flutterwave.com/us/privacy-notice).
-Donation Form.
-```
-[flw-donation-form]
-```
+## Troubleshooting
+
+- **The form doesn't show:** save your API keys and all three redirect pages under **Flutterwave > Settings**.
+- **`authorization` or `validation` errors:** check that both keys are from the same mode (test or live) and were pasted in full.
+- **Payments stay pending:** make sure the webhook URL and secret hash in your Flutterwave dashboard match the ones in **Flutterwave > Settings > API & Webhook**.
+- **`server` errors:** contact support (below).
-####2. Visual Composer
+## Support
-The shortcode can be added via Visual Composer elements.
+Email the developer experience team at [developers@flutterwavego.com](mailto:developers@flutterwavego.com), or [open an issue](https://github.com/Flutterwave/WordPress-v2/issues) on GitHub.
-* On Visual Composer __Add Element__ dialog, click on "__Rave Forms__" and select the type of form you want to include on your page.
- 
-###
+## Development
-* On the "Form Settings" dialog, fill in the form attributes and click "__Save Changes__".
- 
-###
+Requirements: PHP 7.4+, Composer, Node.js 24 (see `.nvmrc`) and Docker (for `wp-env`).
-* Payment Form successfully added to the page.
- 
-###
+```bash
+npm ci # installs npm and Composer dependencies
+npm run env:start # WordPress at http://localhost:8888 (admin / password)
+```
-## Transaction List ##
+| Command | What it runs |
+| --- | --- |
+| `npm run start` | Rebuild the admin app (`client/admin`) on every change |
+| `npm run build:admin` | Build the admin app into `build/` once |
+| `npm run test:js` | Vitest tests for the admin app (`client/**/test`) |
+| `npm run test:unit` | Fast PHPUnit + Brain Monkey tests in `tests/Unit` (no WordPress needed) |
+| `npm run test:integration` | PHPUnit against the WordPress test suite inside wp-env (`tests/Integration`) |
+| `npm run test:e2e` | Playwright browser tests against the wp-env site (`tests/e2e`); run `npm run test:e2e:install` once first |
+| `npm test` | All of the above |
+| `npm run lint` | PHPCS (WordPress Coding Standards 3), ESLint and Stylelint |
+| `npm run audit` | `npm audit` and `composer audit` |
+| `npm run build` | Minified JS, translation template and `rave-payment-forms.zip` |
-All the payments made through the forms to Rave can be accessed on __Rave > Transactions__ page.
+The onboarding wizard and settings screen are a React app in `client/admin`, built with `@wordpress/scripts` and mounted on **Flutterwave > Settings**. It reads and writes settings through the `flutterwave/v1/settings` REST route (`includes/admin`). `build/` is not committed, so run `npm run build:admin` before opening the settings page in a checkout of the repository.
-
+Blocks live in `blocks/` (block.json and server render) and `client/blocks` (editor). Shortcodes are in `includes/shortcodes`, and the Easy Digital Downloads and GiveWP gateways are in `includes/integrations`.
-## Contribution guidelines
+CI (`.github/workflows/ci.yml`) runs linting, dependency audits, unit tests on PHP 7.4–8.5, integration tests on WordPress 6.4 and latest, and the end-to-end suite on every push and pull request.
-We love to get your input. Read more about our community contribution guidelines [here](/CONTRIBUTING.md)
+Contributions are welcome: open an issue or a pull request on [GitHub](https://github.com/Flutterwave/WordPress-v2). See [CHANGELOG.md](CHANGELOG.md) for release notes.
## License
-By contributing to the Rave WooCommerce Plugin, you agree that your contributions will be licensed under its [MIT license](/LICENSE).
+Released under the [MIT license](LICENSE). By contributing, you agree that your contributions will be licensed under it.
-Copyright (c) Flutterwave Inc.
\ No newline at end of file
+Copyright (c) Flutterwave Inc.
diff --git a/readme.txt b/readme.txt
index b8e20bf..881b024 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,182 +1,219 @@
-=== Flutterwave Payments ===
-Contributors: flutterwave
-Tags: rave, payment form, payment gateway, bank account, credit card, debit card, nigeria, kenya, international, mastercard, visa, flutterwave
-Donate link: http://rave.flutterwave.com/
-Requires at least: 4.4
-Tested up to: 6.3
-Requires PHP: 7.4
-Stable tag: 1.0.7
-License: MIT
-License URI: https://github.com/Flutterwave/rave-payment-forms/blob/master/LICENSE
-
-Accept Credit card, Debit card and Bank account payment directly on your WordPress site with the Flutterwave Payments Plugin.
-
-== Description ==
-Accept Credit card, Debit card and Bank account payment directly on your store with the official Flutterwave Plugin for WordPress.
-
-= Plugin Features =
-
-* Collections: Card, Account, Mobile money, Bank Transfers, USSD, Barter, 1voucher.
-* Recurring payments: Tokenization and Subscriptions.
-* Split payments: Split payments between multiple recipients.
-
-= Requirements =
-
-1. Flutterwave for business [API Keys](https://developer.flutterwave.com/docs/integration-guides/authentication)
-2. Supported PHP version: 5.6.0 - 7.4.0
-
-== Installation ==
-
-= Automatic Installation =
-
-* Login to your WordPress Dashboard.
-* Click on "Plugins > Add New" from the left menu.
-* In the search box type Flutterwave Payments.
-* Click on Install Now on Flutterwave Payments to install the plugin on your site.
-* Confirm the installation.
-* Activate the plugin.
-* Go to "Rave > Settings" from the left menu to configure the plugin.
-
-
-= Manual Installation =
-
-* Download the plugin zip file.
-* Login to your WordPress Admin. Click on \"Plugins > Add New\" from the left menu.
-* Click on the \"Upload\" option, then click \"Choose File\" to select the zip file you downloaded. Click \"OK\" and \"Install Now\" to complete the installation.
-* Activate the plugin.
-* Go to \"Rave > Settings\" from the left menu to configure the plugin.
-* For FTP manual installation, check here.
-
-For FTP manual installation, [check here](http://codex.wordpress.org/Managing_Plugins#Manual_Plugin_Installation).
-
-= Configuration options =
-
-* Pay Button Public Key (live/Test) - Enter your public key which can be retrieved from Settings > API on your Rave account dashboard.
-* Pay Button Secret Key (live/Test) - Enter your secret key which can be retrieved from Settings > API on your Rave account dashboard.
-* Go Live - Tick that section to turn your rave plugin live.
-* Modal Title - (Optional) customize the title of the Pay Modal. Default is FLW PAY.
-* Modal Description - (Optional) customize the description on the Pay Modal. Default is FLW PAY MODAL.
-* Modal Logo - (Optional) customize the logo on the Pay Modal. Enter a full url (with \'http\'). Default is Rave logo.
-* Success Redirect URL - (Optional) The URL the user should be redirected to after a successful payment. Enter a full url (with 'http:\\'). Default: '\'.
-* Failed Redirect URL - (Optional) The URL the user should be redirected to after a failed payment. Enter a full url (with 'http:\\'). Default: '\'.
-* Pay Button Text - (Optional) The text to display on the button. Default: "PAY NOW".
-* Charge Currency - (Optional) The currency the user is charged. Default: "NGN".
-* Charge Country - (Optional) The country the merchant is serving. Default: "NG: Nigeria".
-* Form Style - (Optional) Disable form default style and use the activated theme style instead.
-* Click Save Changes to save your changes.
-
-= Styling =
-
-You can enable default theme's style to override default form style from the Settings page. Or you can override the formclass .flw-simple-pay-now-form from your stylesheet.
-
-= Usage =
-
-* a. Shortcode
-Insert the shortcode anywhere on your page or post that you want the form to be displayed to the user.
-Basic: requires the user to enter amount and email to complete payment
-[flw-pay-button]
-
-
-With button text:
-[flw-pay-button]Button Text[/flw-pay-button]
-
-
-With attributes: email or use_current_user_email with value "yes", amount
-[flw-pay-button amount="1290" email="customer@email.com" ]
-
-or
-
-[flw-pay-button amount="1290" use_current_user_email="yes" ]
-
-
-With attributes and button text: email, amount
-[flw-pay-button amount="1290" email="customer@email.com" ]Button Text[/flw-pay-button]
-
-
-
-With currency
-
-[flw-pay-button custom_currency="NGN,GBP,USD"]
-
-With attributes: email or use_current_user_email with value "yes", amount and currency
-[flw-pay-button amount="1290" email="customer@email.com" custom_currency= "NGN, GBP, USD" ]
-
-or
-
-[flw-pay-button amount="1290" use_current_user_email="yes" custom_currency= "NGN, GBP, USD" ]
-
-With currency:
-[flw-pay-button custom_currency="NGN,GBP,USD"]
-
-With attributes: email or use_current_user_email with value "yes", amount and currency
-[flw-pay-button amount="1290" email="customer@email.com" custom_currency= "NGN, GBP, USD" ]
-
-or
-
-[flw-pay-button amount="1290" use_current_user_email="yes" custom_currency= "NGN, GBP, USD" ]
-
-
-* b. Visual Composer
-The shortcode can be added via Visual Composer elements.
-On Visual Composer Add Element dialog, click on "Rave Forms" and select the type of form you want to include on your page.
-
-
-On the "Form Settings" dialog, fill in the form attributes and click "Save Changes".
-
-Payment Form successfully added to the page.
-
-= Best Practices =
-1. When in doubt about a transaction, always check the Flutterwave Dashboard to confirm the status of a transaction.
-2. Always ensure you keep your API keys securely and privately. Do not share with anyone
-3. Ensure you change from the default secret hash on the Wordpress admin and apply same on the Flutterwave Dashboard
-4. Always ensure you install the most recent version of the Flutterwave Wordpress plugin
-
-= Debugging Errors =
-
-We understand that you may run into some errors while integrating our plugin. You can read more about our error messages [here](https://developer.flutterwave.com/docs/integration-guides/errors).
-
-For `authorization` and `validation` error responses, double-check your API keys and request. If you get a `server` error, kindly engage the team for support.
-
-
-= Support =
-
-For additional assistance using this library, contact the developer experience (DX) team via [email](mailto:developers@flutterwavego.com) or on [slack](https://bit.ly/34Vkzcg).
-
-You can also follow us [@FlutterwaveEng](https://twitter.com/FlutterwaveEng) and let us know what you think 😊.
-
-= Contribution guidelines =
-
-We love to get your input. Read more about our community contribution guidelines [here](/CONTRIBUTING.md)
-
-= License =
-
-By contributing to the Flutterwave WooCommerce, you agree that your contributions will be licensed under its [MIT license](/LICENSE).
-
-== Screenshots ==
-
-1. To configure the plugin, go to Rave > Settings from the left menu.
-2. On Visual Composer Add Element dialog, click on "Rave Forms" and select the type of form you want to include on your page.
-3. On the "Form Settings" dialog, fill in the form attributes and click "Save Changes".
-4. Payment Form successfully added to the page.
-5. All the payments made through the forms to Rave can be accessed on Rave > Transactions page.
-
-
-
-== Changelog ==
-v1.0.3
-* This version allows you to add additional fields to the form.
-* This version allow you to set default values to fields.
-* This version allows you to hid fields "By appending '-h' to the name of the field".
-* This version now allow for mobile money option in UGx,TZS,GHS respectively
-v1.0.2
-*Recuring payment section add under "Payment Plan"
-v 1.0.1
-* Recurring payments now enabled.
-
-v 1.0.0
-
-== Upgrade Notice ==
-v1.0.1 - 12-02-2018
-* This version doesn't redirect after failure, it allows the customer try payment again.
-* This version allows you use multiple currencies on the Wordpress payment form.
-* This version now has recurring payments.
+=== Flutterwave Payments ===
+Contributors: flutterwave
+Tags: flutterwave, payment form, donation, mobile money, payment gateway
+Requires at least: 6.4
+Tested up to: 7.1
+Requires PHP: 7.4
+Stable tag: 1.1.0
+License: MIT
+License URI: https://opensource.org/licenses/MIT
+
+Take payments and donations on any WordPress page, and let customers pay with cards, bank transfer, mobile money, Apple Pay or Google Pay.
+
+== Description ==
+
+**Flutterwave Payments is the official Flutterwave plugin for WordPress.** Add a payment button, payment form, donation form or pricing card to any page. Customers pay on Flutterwave's secure checkout with the method they already use, and the money goes to your Flutterwave account.
+
+You don't need an online store, a developer or any code. A guided setup takes a few minutes, and you can try everything with test keys before taking real money.
+
+= Why use Flutterwave Payments =
+
+* **Let customers pay the way they want.** Cards (Visa, Mastercard, American Express), bank transfer, mobile money, Apple Pay, Google Pay, Opay and stablecoins (USDT and USDC). USSD, QR, NQR, Barter and Credit are also available.
+* **Accept local and international money.** Charge in NGN, GHS, KES, ZAR, TZS, UGX, RWF, ZMW, USD, GBP or EUR, or let customers choose.
+* **Go live in minutes.** Guided setup walks you through your business details, API keys, webhook and payment methods. It creates your webhook secret for you and tells you whether you're using test or live keys.
+* **Use the block editor.** Five blocks, each with its own colours, corner radius and button text.
+* **Collect regular donations.** Donors can give once, monthly or yearly, and you can suggest amounts.
+* **See every payment in WordPress.** Filter by date, status and currency, check a payment again with Flutterwave, and download a CSV for your accounts.
+* **Know what each form earns.** The Payment Forms screen lists every form on your site, the page it's on and the payments it has taken.
+* **Connect the plugins you already use.** Easy Digital Downloads and GiveWP gateways are built in, and the Flutterwave WooCommerce extension installs in one click.
+
+= Blocks =
+
+Open the block inserter and choose the **Flutterwave** category.
+
+* **Payment Button:** an email field and a pay button for a fixed amount, like "Pay NGN 5,000". Logged-in users can skip the email.
+* **Payment Form:** a full form with an optional heading and description, and a fixed amount or one the customer enters. Name and phone fields are optional.
+* **Donation Form:** one-off, monthly or yearly donations, with up to six suggested amounts.
+* **Pricing Card:** a plan or product card with a price, a list of features, an optional badge and a checkout button.
+* **Payment Methods:** a badge that shows customers which payment methods you accept, kept in step with your settings.
+
+Button text can include `{amount}`, which is replaced with the formatted price.
+
+= Shortcodes =
+
+Prefer shortcodes, or using a page builder? Paste one into any page, post or widget. **Flutterwave > Payment Forms** has a builder with a live preview that writes the shortcode for you.
+
+**Payment form:** `[flw-pay-form]`
+
+* `amount="5000"`: fixed amount. Leave it out to let the customer enter one.
+* `currency="NGN,USD,GBP"`: one currency, or several for the customer to choose from.
+* `heading="Book a session"` and `description="..."`: text above the form.
+* `use_current_user_email="yes"`: fills in the logged-in user's email.
+* `split_name="1"`: separate first and last name fields.
+* `exclude="phone"`: hides a field (`phone`, `fullname` or a custom field).
+* `custom_fields="company:text,size:select|Small:S|Large:L"`: adds your own fields.
+* `order="email,fullname,amount,currency"`: changes the field order.
+* `layout="compact"`, `width="full"`, `show_secured="0"`: change the look.
+* Button text goes between the tags: `[flw-pay-form amount="5000"]Pay {amount}[/flw-pay-form]`.
+
+**Donation form:** `[flw-donation-form]`
+
+* `heading` and `message`: text above the form.
+* `currency="USD"`: the donation currency.
+* `amounts="1000,5000,10000"`: suggested amounts.
+* `show_frequency="0"`: one-off donations only.
+
+`[flw-pay-button]` from earlier versions still works.
+
+= Works with =
+
+* **Easy Digital Downloads:** sell ebooks, software, music and other downloads with Flutterwave at checkout.
+* **GiveWP:** collect donations through GiveWP forms and campaigns.
+* **WooCommerce:** install the official Flutterwave WooCommerce extension from **Flutterwave > Integrations**.
+* **Classic Editor and WPBakery:** a toolbar button and a "Flutterwave Simple Form" element insert a payment form.
+
+WPForms, Gravity Forms, Contact Form 7, Paid Memberships Pro, MemberPress, LearnDash, Tutor LMS and Event Tickets are coming soon. You can tell us which one you need from the Integrations screen.
+
+= Secure by design =
+
+* Customers enter payment details on Flutterwave's checkout, never on your site.
+* The amount and currency are signed on your server, so they can't be changed in the browser.
+* Every payment is confirmed with Flutterwave before it's marked successful.
+* Webhooks are only accepted with your secret hash, which is checked in constant time.
+* Secret keys are masked in the admin, and checkout requests are rate-limited.
+
+== Installation ==
+
+= What you need =
+
+* A [Flutterwave account](https://app.flutterwave.com/register). You can sign up for free and use test mode straight away.
+* WordPress 6.4 or later and PHP 7.4 or later.
+
+= Set up in five steps =
+
+1. In your WordPress dashboard, go to **Plugins > Add New**, search for **Flutterwave Payments**, then click **Install Now** and **Activate**.
+2. Go to **Flutterwave > Settings** and click **Activate Flutterwave** to start the guided setup.
+3. **General details:** add your business name, logo and default currency.
+4. **API & webhook:** paste your public and secret keys from **Settings > API Keys** in your Flutterwave dashboard. Then copy the webhook URL and secret hash from the plugin into **Settings > Webhooks** in the Flutterwave dashboard.
+5. **Payment methods and redirects:** choose the methods you want to offer, and pick the pages customers see after a successful, failed or pending payment.
+
+Now add a Flutterwave block or shortcode to any page. To try it first, use your test keys (they start with `FLWPUBK_TEST-`), and switch to live keys when you're ready.
+
+You can change any setting later from the **General**, **API & Webhook**, **Payment Methods** and **Redirects** tabs.
+
+= Manual installation =
+
+Download the plugin zip, go to **Plugins > Add New > Upload Plugin**, choose the file and click **Install Now**, then activate it and follow the steps above.
+
+== Frequently Asked Questions ==
+
+= Do I need a Flutterwave account? =
+
+Yes. Payments go to your Flutterwave account. [Sign up here](https://app.flutterwave.com/register). It's free to create an account, and you can test straight away.
+
+= How much does it cost? =
+
+The plugin is free. Flutterwave charges a fee on each successful payment. See the pricing page for your country on [flutterwave.com](https://flutterwave.com).
+
+= Can I test without real money? =
+
+Yes. Enter your test keys (`FLWPUBK_TEST-…` and `FLWSECK_TEST-…`) and the plugin shows that it's in test mode. Pay with one of Flutterwave's [test cards](https://developer.flutterwave.com/docs/testing); no real money moves.
+
+= Which currencies and countries are supported? =
+
+Forms can charge in NGN, GHS, KES, ZAR, TZS, UGX, RWF, ZMW, USD, GBP and EUR. Which payment methods your customers see depends on the currency and their country. Mobile money, for example, is offered in the countries where it's available.
+
+= My form isn't showing on the page. Why? =
+
+A form appears once your API keys and all three redirect pages (success, failed and pending) are saved. Until then, editors see a short notice with a link to the setting that's missing, and visitors see nothing.
+
+= Why do I need a webhook? =
+
+A webhook lets Flutterwave tell your site when a payment finishes, even if the customer closes the browser before returning to your site. Copy the webhook URL and secret hash from **Flutterwave > Settings > API & Webhook** into **Settings > Webhooks** in your Flutterwave dashboard. Without a secret hash, webhooks are refused.
+
+= Can I take recurring payments? =
+
+Donation forms let donors give monthly or yearly. The plugin creates the payment plan on Flutterwave for you.
+
+= Does it work with WooCommerce? =
+
+Yes. Install the official Flutterwave WooCommerce extension from **Flutterwave > Integrations**. For downloads and donations, the Easy Digital Downloads and GiveWP gateways are built into this plugin.
+
+= Where do I see my payments? =
+
+Under **Flutterwave > Transactions**. You can filter, check a payment again with Flutterwave, delete records and download a CSV. **Flutterwave > Payment Forms** shows what each form has taken. Your Flutterwave dashboard is always the final record.
+
+= Can I match the forms to my theme? =
+
+Yes. Blocks have colour, button text colour and corner radius controls. To use your theme's styles instead, turn on the theme style option under **Payment Methods** in the settings. Developers can also target `.flw-simple-pay-now-form` in CSS.
+
+= Something went wrong. Where can I get help? =
+
+Check your API keys first. Most `authorization` and `validation` errors come from a missing key or a test/live mix-up. For anything else, email [developers@flutterwavego.com](mailto:developers@flutterwavego.com) or open an issue on [GitHub](https://github.com/Flutterwave/WordPress-v2/issues).
+
+== External services ==
+
+This plugin connects to services run by Flutterwave Technology Solutions.
+
+**Flutterwave payments API** (api.flutterwave.com) processes payments made through your forms. When a customer submits a form, the plugin sends the amount, currency, the customer's email, name and phone number, and a transaction reference to create the payment, then asks the API to verify it when the customer returns or a webhook arrives. The plugin also reads your account's transactions for the Transactions screen.
+
+**Flutterwave integration analytics** (signozservice-prod.f4b-flutterwave.com) helps Flutterwave see whether integrations are working. The plugin sends:
+
+* when your site is first set up: your public API key, the plugin name and version;
+* when a payment starts: the transaction reference, whether you use test or live keys, and the plugin version;
+* when a live payment succeeds: the transaction reference, amount, currency, fee and payment method;
+* when something goes wrong: an error code and message, with the transaction reference where there is one.
+
+No customer names, email addresses, phone numbers or card details are sent to the analytics service. To turn it off, add `define( 'FLW_DISABLE_TELEMETRY', true );` to wp-config.php, or return false from the `flw_signoz_enabled` filter.
+
+Both services are covered by Flutterwave's [Terms of Service](https://flutterwave.com/us/terms) and [Privacy Notice](https://flutterwave.com/us/privacy-notice).
+
+== Screenshots ==
+
+1. Go to Flutterwave > Settings and click "Activate Flutterwave" to start the guided setup.
+2. Add your Flutterwave API keys, then copy the webhook URL and secret hash into your Flutterwave dashboard.
+3. Choose the payment methods customers can use: cards, bank transfer, mobile money, Apple Pay, Google Pay and more.
+4. Change your settings at any time from the General, API & Webhook, Payment Methods and Redirects tabs.
+5. See every payment made through your forms under Flutterwave > Transactions.
+6. Add a payment form to any page with the [flw-pay-form] shortcode.
+7. Collect one-off or recurring donations with the [flw-donation-form] shortcode.
+
+== Changelog ==
+
+= 1.1.0 =
+* New: guided onboarding and a redesigned tabbed settings screen with webhook setup and test/live key detection.
+* New: Payment Button, Payment Form, Donation Form, Pricing Card and Accepted Payment Methods blocks.
+* New: Payment Forms screen showing every form in use, the page it is on and the payments it has taken, plus a form builder.
+* New: redesigned Transactions screen with filters, a details drawer and CSV export for a date range.
+* New: Easy Digital Downloads and GiveWP gateways, and one-click install of the Flutterwave WooCommerce extension.
+* New: choose which payment methods are offered at checkout.
+* New: more shortcode options for headings, descriptions, layout, suggested donation amounts and currency.
+* Changed: payment and donation forms use the new Flutterwave design, and styles only load on pages with a form.
+* Security: payment amount and currency are enforced on the server, and transactions are verified against the stored record.
+* Security: webhooks are rejected unless a secret hash is configured.
+* Security: fixed stored XSS in shortcode attributes and the transactions list; added checkout rate limiting.
+* Fixed: checkout errors are now shown to customers, and name fields are shown on the payment form again.
+* Fixed: translations loading too early on WordPress 6.7+.
+
+= 1.0.3 =
+* Add extra fields to the form.
+* Set default values for fields.
+* Hide a field by adding "-h" to its name.
+* Mobile money for UGX, TZS and GHS.
+
+= 1.0.2 =
+* Recurring payments section added under "Payment Plan".
+
+= 1.0.1 =
+* Recurring payments enabled.
+
+= 1.0.0 =
+* First release.
+
+== Upgrade Notice ==
+
+= 1.1.0 =
+Security release. Webhooks are now rejected unless a secret hash is set: after updating, add one under Flutterwave > Settings and in your Flutterwave dashboard.
+
+= 1.0.1 =
+Failed payments no longer redirect, so customers can try again. Adds multiple currencies and recurring payments.
diff --git a/src/API/class-client.php b/src/API/class-client.php
index a812125..9d8e909 100644
--- a/src/API/class-client.php
+++ b/src/API/class-client.php
@@ -91,7 +91,8 @@ private function get_base_url(): string {
* @return array|\WP_Error
*/
public function request( string $url, string $method = 'GET', array $data = array() ) {
- $_request_url = $this->get_base_url() . $url; // url should be prefixed with a "/" .
+ $_request_url = $this->get_base_url() . $url;
+ // url should be prefixed with a "/" .
$wp_args['method'] = $method;
$wp_args['timeout'] = $this->timeout;
$wp_args['body'] = \wp_json_encode( $data, JSON_UNESCAPED_SLASHES );
diff --git a/src/API/class-handler.php b/src/API/class-handler.php
index 355e733..017d009 100644
--- a/src/API/class-handler.php
+++ b/src/API/class-handler.php
@@ -28,7 +28,7 @@ public static function handle_api_errors( $response ) {
$error_hash_table = self::get_error_hash_table();
if ( isset( $error_hash_table[ $response_status_code ] ) && 400 !== $error_hash_table[ $response_status_code ] ) {
- throw new ApiException( $error_hash_table[ $response_status_code ] );
+ throw new ApiException( $error_hash_table[ $response_status_code ] ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- carries a WP_Error, never rendered directly.
}
}
diff --git a/src/Helper/class-webhookhelper.php b/src/Helper/class-webhookhelper.php
index 0ae108d..8af65dd 100644
--- a/src/Helper/class-webhookhelper.php
+++ b/src/Helper/class-webhookhelper.php
@@ -14,24 +14,29 @@ final class WebhookHelper {
/**
* Compare hashes.
*
+ * Fails closed when either side is empty, so an unconfigured secret hash never authenticates a request.
+ *
* @param string $expected local hash.
* @param string $actual recieved hash.
*
* @return bool
*/
public static function compare_secret_hash( string $expected, string $actual ): bool {
- return true;
+ if ( '' === $expected || '' === $actual ) {
+ return false;
+ }
+
+ return hash_equals( $expected, $actual );
}
/**
* Validate Hook Data.
*
- * @param object $hook notification sent by flutterwave.
+ * @param array $hook notification sent by flutterwave.
*
* @return bool
*/
- public static function validate_hook_body( object $hook ): bool {
- return true;
+ public static function validate_hook_body( array $hook ): bool {
+ return isset( $hook['event'], $hook['data'] ) && is_string( $hook['event'] ) && is_array( $hook['data'] );
}
-
}
diff --git a/src/Integrations/class-abstractservice.php b/src/Integrations/class-abstractservice.php
index e503637..0502911 100644
--- a/src/Integrations/class-abstractservice.php
+++ b/src/Integrations/class-abstractservice.php
@@ -80,14 +80,14 @@ abstract public function get_assets(): array;
*
* @return array
*/
- abstract public function get_info() : array;
+ abstract public function get_info(): array;
/**
* Get Header.
*
* @return array
*/
- abstract protected function get_headers() : array;
+ abstract protected function get_headers(): array;
/**
* Service Constructor.
@@ -105,7 +105,7 @@ public function __construct( string $key ) {
*
* @return void
*/
- public function set_key( string $key ):void {
+ public function set_key( string $key ): void {
$this->api_key = $key;
}
@@ -155,8 +155,7 @@ protected function request( string $url, string $method = 'GET', array $data = a
return \WP_Error(
'flw-unavailable',
/* translators: %s: owner's name, %s: service name */
- sprintf( '%s \'s %s service is currently unavailable. please use another integration.', $this->owner, $this->name )
+ sprintf( '%s \'s %s service is currently unavailable. please use another integration.', $this->owner, $this->name )
);
}
-
}
diff --git a/tests/Integration/AdminPageTest.php b/tests/Integration/AdminPageTest.php
new file mode 100644
index 0000000..e666ca6
--- /dev/null
+++ b/tests/Integration/AdminPageTest.php
@@ -0,0 +1,140 @@
+is_built() ) {
+ $this->assertStringContainsString( 'id="flutterwave-admin-root"', $html );
+ } else {
+ $this->assertStringContainsString( 'npm run build:admin', $html );
+ }
+ }
+
+ /**
+ * @dataProvider screens
+ *
+ * @param string $method Render method.
+ * @param string $screen Expected data-screen value.
+ */
+ public function test_list_screens_render_their_root( string $method, string $screen ) {
+ if ( ! $this->is_built() ) {
+ $this->markTestSkipped( 'Run `npm run build:admin` to build the admin app.' );
+ }
+
+ ob_start();
+ call_user_func( array( FLW_Admin_Settings::class, $method ) );
+ $html = ob_get_clean();
+
+ $this->assertStringContainsString( 'id="flutterwave-admin-root"', $html );
+ $this->assertStringContainsString( 'data-screen="' . $screen . '"', $html );
+ }
+
+ /**
+ * Screen render methods.
+ *
+ * @return array
+ */
+ public static function screens(): array {
+ return array(
+ 'payment forms' => array( 'render_forms_page', 'forms' ),
+ 'transactions' => array( 'render_transactions_page', 'transactions' ),
+ 'integrations' => array( 'render_integrations_page', 'integrations' ),
+ );
+ }
+
+ public function test_app_is_enqueued_only_on_the_settings_page() {
+ if ( ! $this->is_built() ) {
+ $this->markTestSkipped( 'Run `npm run build:admin` to build the admin app.' );
+ }
+
+ $settings = FLW_Admin_Settings::get_instance();
+
+ $settings->enqueue_admin_app( 'index.php' );
+ $this->assertFalse( wp_script_is( 'flw-admin', 'enqueued' ) );
+
+ $settings->enqueue_admin_app( FLW_Admin_Settings::HOOK_SUFFIX );
+ $this->assertTrue( wp_script_is( 'flw-admin', 'enqueued' ) );
+ $this->assertTrue( wp_style_is( 'flw-fonts', 'enqueued' ) );
+ $this->assertStringContainsString( 'flutterwaveAdminData', (string) wp_scripts()->get_data( 'flw-admin', 'data' ) );
+ }
+
+ public function test_every_screen_is_registered_and_loads_the_app() {
+ if ( ! $this->is_built() ) {
+ $this->markTestSkipped( 'Run `npm run build:admin` to build the admin app.' );
+ }
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ FLW_Admin_Settings::get_instance()->flw_rave_add_admin_menu();
+
+ $pages = array(
+ FLW_Admin_Settings::PAGE_SLUG => 'settings',
+ FLW_Admin_Settings::FORMS_SLUG => 'forms',
+ FLW_Admin_Settings::TRANSACTIONS_SLUG => 'transactions',
+ FLW_Admin_Settings::INTEGRATIONS_SLUG => 'integrations',
+ );
+
+ foreach ( $pages as $slug => $screen ) {
+ $hook = get_plugin_page_hookname( $slug, FLW_Admin_Settings::PAGE_SLUG );
+
+ $this->assertSame( $screen, FLW_Admin_Settings::screen_for( $hook ), $slug );
+
+ wp_dequeue_script( 'flw-admin' );
+ FLW_Admin_Settings::get_instance()->enqueue_admin_app( $hook );
+ $this->assertTrue( wp_script_is( 'flw-admin', 'enqueued' ), $slug );
+ }
+
+ $data = (string) wp_scripts()->get_data( 'flw-admin', 'data' );
+
+ foreach ( array( 'exportUrl', 'formsUrl', 'onboarded', 'tour', 'userId', 'supportForum' ) as $key ) {
+ $this->assertStringContainsString( '"' . $key . '"', $data );
+ }
+ }
+
+ public function test_setup_notice_links_to_onboarding() {
+ delete_option( 'flw_rave_options' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ set_current_screen( 'dashboard' );
+
+ ob_start();
+ \Flutterwave_Payments::get_instance()->admin_notices();
+ $html = ob_get_clean();
+
+ $this->assertStringContainsString( esc_url( FLW_Admin_Settings::get_url() ), $html );
+ }
+
+ public function test_setup_notice_is_hidden_on_the_settings_page() {
+ delete_option( 'flw_rave_options' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ set_current_screen( FLW_Admin_Settings::HOOK_SUFFIX );
+
+ ob_start();
+ \Flutterwave_Payments::get_instance()->admin_notices();
+
+ $this->assertSame( '', ob_get_clean() );
+ }
+}
diff --git a/tests/Integration/BlocksTest.php b/tests/Integration/BlocksTest.php
new file mode 100644
index 0000000..000f8f5
--- /dev/null
+++ b/tests/Integration/BlocksTest.php
@@ -0,0 +1,246 @@
+configure_plugin();
+ }
+
+ /**
+ * Render one block from its attributes.
+ *
+ * @param string $name Block name without the namespace.
+ * @param array $attributes Block attributes.
+ *
+ * @return string
+ */
+ private function render( string $name, array $attributes = array() ): string {
+ $json = $attributes ? ' ' . wp_json_encode( $attributes ) : '';
+
+ return do_blocks( '' );
+ }
+
+ /**
+ * The signed form config in rendered HTML.
+ *
+ * @param string $html Rendered block.
+ *
+ * @return array
+ */
+ private function signed_config( string $html ): array {
+ preg_match( '/name="flw_form_config" value="([^"]+)"/', $html, $payload );
+ preg_match( '/name="flw_form_sig" value="([^"]+)"/', $html, $signature );
+
+ $config = FLW_Form_Config::verify( html_entity_decode( $payload[1] ?? '' ), $signature[1] ?? '' );
+ $this->assertIsArray( $config, 'The block should carry a valid signed config.' );
+
+ return $config;
+ }
+
+ public function test_blocks_are_registered_in_the_flutterwave_category() {
+ foreach ( FLW_Blocks::BLOCKS as $block ) {
+ $type = WP_Block_Type_Registry::get_instance()->get_registered( 'flutterwave/' . $block );
+
+ $this->assertNotNull( $type, $block );
+ $this->assertSame( 'flutterwave', $type->category );
+ $this->assertTrue( $type->is_dynamic(), $block . ' is rendered on the server.' );
+ }
+
+ $slugs = wp_list_pluck( get_block_categories( get_post( self::factory()->post->create() ) ), 'slug' );
+ $this->assertContains( 'flutterwave', $slugs );
+ }
+
+ public function test_payment_button_signs_its_amount_and_labels_the_button() {
+ $html = $this->render(
+ 'payment-button',
+ array(
+ 'amount' => 5000,
+ 'currency' => 'NGN',
+ 'useUserEmail' => false,
+ )
+ );
+
+ $this->assertStringContainsString( 'flw-layout-compact', $html );
+ $this->assertStringContainsString( '>Pay NGN 5,000', $html );
+ $this->assertStringContainsString( 'id="flw-customer-email"', $html );
+ $this->assertStringNotContainsString( 'id="flw-full-name"', $html );
+ $this->assertStringNotContainsString( 'id="flw-phone"', $html );
+
+ $config = $this->signed_config( $html );
+ $this->assertEquals( 5000, $config['amount'] );
+ $this->assertSame( array( 'NGN' ), $config['currencies'] );
+ }
+
+ public function test_payment_button_uses_the_logged_in_users_email() {
+ wp_set_current_user( self::factory()->user->create( array( 'user_email' => 'member@example.com' ) ) );
+
+ $html = $this->render( 'payment-button', array( 'amount' => 10 ) );
+
+ $this->assertStringNotContainsString( 'id="flw-customer-email"', $html );
+ $this->assertStringContainsString( 'data-email="member@example.com"', $html );
+ }
+
+ public function test_payment_button_defaults_to_the_site_currency() {
+ $this->configure_plugin( array( 'currency' => 'KES' ) );
+
+ $this->assertSame( array( 'KES' ), $this->signed_config( $this->render( 'payment-button', array( 'amount' => 10 ) ) )['currencies'] );
+ }
+
+ public function test_style_settings_become_css_variables_and_bad_values_are_dropped() {
+ $html = $this->render(
+ 'payment-button',
+ array(
+ 'amount' => 10,
+ 'accentColor' => '#2a3362',
+ 'accentTextColor' => 'red;background:url(https://evil.test)',
+ 'borderRadius' => 999,
+ )
+ );
+
+ $this->assertStringContainsString( '--flw-accent:#2a3362', $html );
+ $this->assertStringContainsString( '--flw-custom-radius:40px', $html );
+ $this->assertStringNotContainsString( 'evil.test', $html );
+ $this->assertStringNotContainsString( '--flw-accent-text', $html );
+ }
+
+ public function test_payment_form_options() {
+ $html = $this->render(
+ 'payment-form',
+ array(
+ 'heading' => 'Pay ',
+ 'description' => 'Invoice payments',
+ 'collectName' => 'split',
+ 'collectPhone' => false,
+ 'buttonText' => 'Pay {amount} now',
+ 'amount' => 1500,
+ 'currency' => 'NGN',
+ )
+ );
+
+ $this->assertStringContainsString( '', $html );
+ $this->assertStringNotContainsString( '',
+ 'currency' => 'KES',
+ )
+ )->get_data();
+
+ // sanitize_text_field() drops Pay' ) );
+
+ $this->assertSame( 'xPay', $clean['modal_title'] );
+ }
+
+ public function test_choices_outside_allowed_values_are_dropped() {
+ $clean = $this->settings()->sanitize_options(
+ array(
+ 'method' => 'card',
+ 'currency' => 'BTC',
+ 'country' => 'NG',
+ 'go_live' => 'yes',
+ 'theme_style' => 'on',
+ )
+ );
+
+ $this->assertSame( 'card', $clean['method'] );
+ $this->assertArrayNotHasKey( 'currency', $clean );
+ $this->assertSame( 'NG', $clean['country'] );
+ $this->assertSame( 'yes', $clean['go_live'] );
+ $this->assertArrayNotHasKey( 'theme_style', $clean );
+ }
+
+ public function test_payment_options_keep_only_known_tokens() {
+ $clean = $this->settings()->sanitize_options(
+ array(
+ 'payment_options' => 'card, applepay,evil,card',
+ 'onboarding_complete' => 'yes',
+ )
+ );
+
+ $this->assertSame( 'card,applepay', $clean['payment_options'] );
+ $this->assertSame( 'yes', $clean['onboarding_complete'] );
+ }
+
+ public function test_currency_and_country_lists_match_the_settings_model() {
+ $clean = $this->settings()->sanitize_options(
+ array(
+ 'currency' => 'UGX',
+ 'country' => 'TZ',
+ )
+ );
+
+ $this->assertSame( 'UGX', $clean['currency'] );
+ $this->assertSame( 'TZ', $clean['country'] );
+ }
+
+ public function test_unknown_keys_are_discarded() {
+ $this->assertSame( array(), $this->settings()->sanitize_options( array( 'evil' => 'x' ) ) );
+ }
+}
diff --git a/tests/Unit/AppRegistrationTest.php b/tests/Unit/AppRegistrationTest.php
new file mode 100644
index 0000000..ded0a9d
--- /dev/null
+++ b/tests/Unit/AppRegistrationTest.php
@@ -0,0 +1,181 @@
+options['flw_rave_options'] = array( 'public_key' => 'FLWPUBK_TEST-public-X' );
+ }
+
+ /**
+ * Registration jobs queued so far.
+ *
+ * @return array
+ */
+ private function jobs(): array {
+ return array_values(
+ array_filter(
+ $this->scheduled,
+ static function ( $event ) {
+ return FLW_App_Registration::REGISTER_HOOK === $event[0];
+ }
+ )
+ );
+ }
+
+ public function test_nothing_is_queued_without_a_public_key() {
+ $this->options['flw_rave_options'] = array();
+
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+
+ $this->assertSame( array(), $this->jobs() );
+ }
+
+ public function test_registration_is_queued_once() {
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+
+ $this->assertSame( array( array( FLW_App_Registration::REGISTER_HOOK, array( 'FLWPUBK_TEST-public-X' ) ) ), $this->jobs() );
+ }
+
+ public function test_nothing_is_queued_when_registered_for_this_key_and_version() {
+ $this->options[ FLW_Signoz_Logger::STATE_OPTION ] = array(
+ 'app_registered' => true,
+ 'public_key' => 'FLWPUBK_TEST-public-X',
+ 'plugin_version' => FLW_PAY_VERSION,
+ );
+
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+
+ $this->assertSame( array(), $this->jobs() );
+ }
+
+ /**
+ * @dataProvider stale_states
+ *
+ * @param array $state Stored registration state.
+ */
+ public function test_stale_registration_is_redone( array $state ) {
+ $this->options[ FLW_Signoz_Logger::STATE_OPTION ] = $state;
+
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+
+ $this->assertCount( 1, $this->jobs() );
+ }
+
+ /**
+ * Registration states that need redoing.
+ *
+ * @return array
+ */
+ public static function stale_states(): array {
+ return array(
+ 'older plugin version' => array(
+ array(
+ 'app_registered' => true,
+ 'public_key' => 'FLWPUBK_TEST-public-X',
+ 'plugin_version' => '1.0.0',
+ ),
+ ),
+ 'different key' => array(
+ array(
+ 'app_registered' => true,
+ 'public_key' => 'FLWPUBK_TEST-old-X',
+ 'plugin_version' => FLW_PAY_VERSION,
+ ),
+ ),
+ );
+ }
+
+ public function test_nothing_is_queued_when_telemetry_is_disabled() {
+ \Brain\Monkey\Filters\expectApplied( 'flw_signoz_enabled' )->andReturn( false );
+
+ FLW_App_Registration::instance()->maybe_enqueue_registration();
+
+ $this->assertSame( array(), $this->jobs() );
+ }
+
+ public function test_upgrade_of_this_plugin_queues_registration() {
+ $self = basename( dirname( FLW_PAY_PLUGIN_FILE ) ) . '/rave-payment-forms.php';
+
+ FLW_App_Registration::instance()->maybe_trigger_on_upgrade(
+ null,
+ array(
+ 'type' => 'plugin',
+ 'action' => 'update',
+ 'plugins' => array( 'other/other.php' ),
+ )
+ );
+ $this->assertSame( array(), $this->jobs() );
+
+ FLW_App_Registration::instance()->maybe_trigger_on_upgrade(
+ null,
+ array(
+ 'type' => 'plugin',
+ 'action' => 'update',
+ 'plugin' => $self,
+ )
+ );
+ $this->assertCount( 1, $this->jobs() );
+ }
+
+ public function test_successful_registration_stamps_the_state() {
+ Functions\when( 'wp_remote_get' )->justReturn( array( 'response' => array( 'code' => 200 ) ) );
+ Functions\when( 'wp_remote_post' )->justReturn( array( 'response' => array( 'code' => 201 ) ) );
+ Functions\when( 'wp_remote_retrieve_response_code' )->alias(
+ static function ( $response ) {
+ return $response['response']['code'];
+ }
+ );
+ Functions\when( 'wp_remote_retrieve_body' )->alias(
+ static function ( $response ) {
+ return 200 === $response['response']['code'] ? '{"status":"ok"}' : '{"app_id":"app-1"}';
+ }
+ );
+
+ FLW_App_Registration::instance()->handle_scheduled_registration( 'FLWPUBK_TEST-public-X' );
+
+ $this->assertSame(
+ array(
+ 'app_id' => 'app-1',
+ 'public_key' => 'FLWPUBK_TEST-public-X',
+ 'app_registered' => true,
+ 'plugin_version' => FLW_PAY_VERSION,
+ ),
+ $this->options[ FLW_Signoz_Logger::STATE_OPTION ]
+ );
+ }
+
+ public function test_failed_registration_leaves_the_state_for_a_retry() {
+ Functions\when( 'wp_remote_get' )->justReturn( new \WP_Error( 'http', 'down' ) );
+
+ FLW_App_Registration::instance()->handle_scheduled_registration( 'FLWPUBK_TEST-public-X' );
+
+ $this->assertArrayNotHasKey( FLW_Signoz_Logger::STATE_OPTION, $this->options );
+ }
+
+ public function test_job_for_an_old_key_does_nothing() {
+ Functions\expect( 'wp_remote_get' )->never();
+
+ FLW_App_Registration::instance()->handle_scheduled_registration( 'FLWPUBK_TEST-old-X' );
+
+ $this->assertArrayNotHasKey( FLW_Signoz_Logger::STATE_OPTION, $this->options );
+ }
+}
diff --git a/tests/Unit/FormConfigTest.php b/tests/Unit/FormConfigTest.php
new file mode 100644
index 0000000..0e786ab
--- /dev/null
+++ b/tests/Unit/FormConfigTest.php
@@ -0,0 +1,114 @@
+signed_config( 50000, array( 'NGN' ) );
+
+ $this->assertSame( array( 'NGN' ), $config['currencies'] );
+ $this->assertEquals( 50000, $config['amount'] );
+ }
+
+ public function test_forged_payload_is_rejected() {
+ $signed = FLW_Form_Config::sign( 50000, array( 'NGN' ) );
+ $forged = base64_encode( json_encode( array( 'amount' => 1, 'currencies' => array( 'NGN' ) ) ) ); // phpcs:ignore
+
+ $this->assertInstanceOf( \WP_Error::class, FLW_Form_Config::verify( $forged, $signed['signature'] ) );
+ }
+
+ public function test_missing_signature_is_rejected() {
+ $signed = FLW_Form_Config::sign( 50000, array( 'NGN' ) );
+
+ $this->assertInstanceOf( \WP_Error::class, FLW_Form_Config::verify( $signed['payload'], '' ) );
+ $this->assertInstanceOf( \WP_Error::class, FLW_Form_Config::verify( '', '' ) );
+ }
+
+ public function test_signature_depends_on_site_salt() {
+ $signed = FLW_Form_Config::sign( 50000, array( 'NGN' ) );
+
+ \Brain\Monkey\Functions\when( 'wp_salt' )->justReturn( 'another-site' );
+
+ $this->assertInstanceOf( \WP_Error::class, FLW_Form_Config::verify( $signed['payload'], $signed['signature'] ) );
+ }
+
+ public function test_fixed_amount_overrides_browser_amount() {
+ $terms = FLW_Form_Config::resolve_terms( $this->signed_config( 50000, array( 'NGN' ) ), '1', 'NGN' );
+
+ $this->assertSame( 50000.0, $terms['amount'] );
+ $this->assertSame( 'NGN', $terms['currency'] );
+ }
+
+ public function test_open_amount_uses_positive_browser_amount() {
+ $terms = FLW_Form_Config::resolve_terms( $this->signed_config( 0, array( 'NGN', 'USD' ) ), '2500.555', 'usd' );
+
+ $this->assertSame( 2500.56, $terms['amount'] );
+ $this->assertSame( 'USD', $terms['currency'] );
+ }
+
+ /**
+ * @dataProvider invalid_amounts
+ *
+ * @param mixed $amount Invalid browser amount.
+ */
+ public function test_open_amount_rejects_invalid_values( $amount ) {
+ $result = FLW_Form_Config::resolve_terms( $this->signed_config( 0, array( 'NGN' ) ), $amount, 'NGN' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'flw-invalid-amount', $result->get_error_code() );
+ }
+
+ /**
+ * Invalid amounts.
+ *
+ * @return array
+ */
+ public static function invalid_amounts(): array {
+ return array(
+ 'zero' => array( '0' ),
+ 'negative' => array( '-5' ),
+ 'text' => array( 'abc' ),
+ 'empty' => array( '' ),
+ 'null' => array( null ),
+ );
+ }
+
+ public function test_currency_outside_form_is_rejected() {
+ $result = FLW_Form_Config::resolve_terms( $this->signed_config( 100, array( 'NGN' ) ), '100', 'USD' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'flw-invalid-currency', $result->get_error_code() );
+ }
+
+ public function test_parse_currencies_normalises_list() {
+ $this->assertSame( array( 'NGN', 'USD' ), FLW_Form_Config::parse_currencies( ' ngn, USD,,ngn' ) );
+ }
+
+ public function test_parse_currencies_uses_fallback_when_empty() {
+ $this->assertSame( array( 'NGN', 'KES' ), FLW_Form_Config::parse_currencies( '', array( 'NGN', 'KES' ) ) );
+ }
+}
diff --git a/tests/Unit/PaymentRecordTest.php b/tests/Unit/PaymentRecordTest.php
new file mode 100644
index 0000000..c59a7ff
--- /dev/null
+++ b/tests/Unit/PaymentRecordTest.php
@@ -0,0 +1,200 @@
+add_post(
+ 10,
+ 'payment_list',
+ array(
+ '_flw_rave_payment_tx_ref' => 'WP_ABC_1',
+ '_flw_rave_payment_amount' => 50000.0,
+ '_flw_rave_payment_currency' => 'NGN',
+ '_flw_rave_payment_status' => 'pending',
+ )
+ );
+ $this->add_post( 11, 'page' );
+ }
+
+ /**
+ * Build a verified transaction payload.
+ *
+ * @param array $overrides Fields to override.
+ *
+ * @return array
+ */
+ private function transaction( array $overrides = array() ): array {
+ return array_merge(
+ array(
+ 'id' => 99,
+ 'tx_ref' => 'WP_ABC_1',
+ 'amount' => 50000,
+ 'currency' => 'NGN',
+ 'status' => 'successful',
+ 'meta' => array(
+ 'order_id' => 10,
+ 'order_amount' => 1,
+ 'order_currency' => 'NGN',
+ ),
+ 'customer' => array(
+ 'name' => ' Bob',
+ 'email' => 'bob@example.com',
+ ),
+ ),
+ $overrides
+ );
+ }
+
+ public function test_underpayment_is_not_successful_even_with_forged_meta() {
+ $status = FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'amount' => 1 ) ) );
+
+ $this->assertSame( 'paid less - remains49999', $status );
+ $this->assertSame( 'paid less - remains49999', $this->meta[10]['_flw_rave_payment_status'] );
+ }
+
+ public function test_full_payment_is_successful() {
+ $this->assertSame( 'successful', FLW_Payment_Record::apply_verified_transaction( $this->transaction() ) );
+ $this->assertSame( 99, $this->meta[10]['_flw_rave_payment_id'] );
+ }
+
+ public function test_customer_details_are_sanitised() {
+ FLW_Payment_Record::apply_verified_transaction( $this->transaction() );
+
+ $this->assertSame( 'Bob', $this->meta[10]['_flw_rave_payment_fullname'] );
+ $this->assertSame( 'bob@example.com', $this->meta[10]['_flw_rave_payment_customer'] );
+ }
+
+ public function test_other_post_types_are_rejected() {
+ $result = FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'meta' => array( 'order_id' => 11 ) ) ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertArrayNotHasKey( '_flw_rave_payment_status', $this->meta[11] );
+ }
+
+ public function test_unknown_record_is_rejected() {
+ $this->assertInstanceOf( \WP_Error::class, FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'meta' => array( 'order_id' => 404 ) ) ) ) );
+ }
+
+ public function test_tx_ref_mismatch_is_rejected() {
+ $result = FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'tx_ref' => 'SOMEONE_ELSE' ) ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'pending', $this->meta[10]['_flw_rave_payment_status'] );
+ }
+
+ public function test_explicit_order_id_takes_precedence_over_meta() {
+ $result = FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'meta' => array( 'order_id' => 11 ) ) ), 10 );
+
+ $this->assertSame( 'successful', $result );
+ }
+
+ public function test_successful_record_is_never_downgraded() {
+ FLW_Payment_Record::apply_verified_transaction( $this->transaction() );
+
+ $this->assertSame( 'successful', FLW_Payment_Record::apply_verified_transaction( $this->transaction( array( 'status' => 'failed' ) ) ) );
+ $this->assertSame( 'successful', $this->meta[10]['_flw_rave_payment_status'] );
+ }
+
+ /**
+ * @dataProvider statuses
+ *
+ * @param float $received_amount Amount received.
+ * @param string $received_currency Currency received.
+ * @param string $gateway_status Flutterwave status.
+ * @param string $expected Expected record status.
+ */
+ public function test_resolve_status( float $received_amount, string $received_currency, string $gateway_status, string $expected ) {
+ $this->assertSame( $expected, FLW_Payment_Record::resolve_status( 100.0, 'NGN', $received_amount, $received_currency, $gateway_status ) );
+ }
+
+ /**
+ * Status cases.
+ *
+ * @return array
+ */
+ public static function statuses(): array {
+ return array(
+ 'exact' => array( 100.0, 'NGN', 'successful', 'successful' ),
+ 'float noise' => array( 100.001, 'NGN', 'successful', 'successful' ),
+ 'underpaid' => array( 60.0, 'NGN', 'successful', 'paid less - remains40' ),
+ 'overpaid' => array( 150.0, 'NGN', 'successful', 'paid more - refund50' ),
+ 'other currency' => array( 100.0, 'USD', 'successful', 'currency diff' ),
+ 'gateway failed' => array( 100.0, 'NGN', 'failed', 'failed' ),
+ 'gateway empty' => array( 100.0, 'NGN', '', 'failed' ),
+ 'gateway injected' => array( 100.0, 'NGN', 'pending ', 'bpendingb' ),
+ );
+ }
+
+ public function test_cancel_requires_matching_tx_ref() {
+ $this->assertFalse( FLW_Payment_Record::cancel( 10, 'WRONG' ) );
+ $this->assertSame( 'pending', $this->meta[10]['_flw_rave_payment_status'] );
+
+ $this->assertTrue( FLW_Payment_Record::cancel( 10, 'WP_ABC_1' ) );
+ $this->assertSame( 'cancelled', $this->meta[10]['_flw_rave_payment_status'] );
+ }
+
+ public function test_cancel_leaves_completed_records_alone() {
+ $this->meta[10]['_flw_rave_payment_status'] = 'successful';
+
+ $this->assertFalse( FLW_Payment_Record::cancel( 10, 'WP_ABC_1' ) );
+ $this->assertSame( 'successful', $this->meta[10]['_flw_rave_payment_status'] );
+ }
+
+ public function test_fetch_rejects_invalid_ids_without_calling_the_api() {
+ \Brain\Monkey\Functions\expect( 'wp_safe_remote_get' )->never();
+
+ $this->assertInstanceOf( \WP_Error::class, FLW_Payment_Record::fetch_verified( 0 ) );
+ $this->assertInstanceOf( \WP_Error::class, FLW_Payment_Record::fetch_verified_by_reference( '' ) );
+ }
+
+ public function test_fetch_uses_numeric_path_and_secret_key() {
+ \Brain\Monkey\Functions\when( 'get_option' )->justReturn( array( 'secret_key' => 'FLWSECK_TEST-x' ) );
+ \Brain\Monkey\Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"status":"success","data":{"id":42}}' );
+ \Brain\Monkey\Functions\expect( 'wp_safe_remote_get' )
+ ->once()
+ ->with(
+ 'https://api.flutterwave.com/v3/transactions/42/verify',
+ \Mockery::on(
+ static function ( $args ) {
+ return 'Bearer FLWSECK_TEST-x' === $args['headers']['Authorization'];
+ }
+ )
+ )
+ ->andReturn( array() );
+
+ $this->assertSame( array( 'id' => 42 ), FLW_Payment_Record::fetch_verified( 42 ) );
+ }
+
+ public function test_fetch_by_reference_encodes_tx_ref() {
+ \Brain\Monkey\Functions\when( 'get_option' )->justReturn( array( 'secret_key' => 'k' ) );
+ \Brain\Monkey\Functions\when( 'add_query_arg' )->alias(
+ static function ( $key, $value, $url ) {
+ return $url . '?' . $key . '=' . $value;
+ }
+ );
+ \Brain\Monkey\Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"status":"error"}' );
+ \Brain\Monkey\Functions\expect( 'wp_safe_remote_get' )
+ ->once()
+ ->with( 'https://api.flutterwave.com/v3/transactions/verify_by_reference?tx_ref=a%26b%3Dc', \Mockery::any() )
+ ->andReturn( array() );
+
+ $this->assertInstanceOf( \WP_Error::class, FLW_Payment_Record::fetch_verified_by_reference( 'a&b=c' ) );
+ }
+}
diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php
new file mode 100644
index 0000000..60a0951
--- /dev/null
+++ b/tests/Unit/SettingsTest.php
@@ -0,0 +1,231 @@
+alias(
+ function ( $name, $fallback = false ) {
+ return FLW_Settings::OPTION_KEY === $name && false !== $this->option ? $this->option : $fallback;
+ }
+ );
+ Functions\when( 'update_option' )->alias(
+ function ( $name, $value ) {
+ $this->option = $value;
+ return true;
+ }
+ );
+ Functions\when( 'home_url' )->justReturn( 'https://shop.test/' );
+ Functions\when( 'rest_url' )->alias(
+ static function ( $path ) {
+ return 'https://shop.test/wp-json/' . $path;
+ }
+ );
+ }
+
+ public function test_selected_methods_become_payment_options() {
+ $this->assertSame( 'card', FLW_Settings::option_from_methods( array( 'card' ) ) );
+ $this->assertSame( 'card,banktransfer,account', FLW_Settings::option_from_methods( array( 'card', 'banktransfer' ) ) );
+ $this->assertSame( 'card,ussd', FLW_Settings::option_from_methods( array( 'card', 'bogus' ), array( 'ussd', 'evil' ) ) );
+ }
+
+ public function test_mobile_money_expands_to_country_variants() {
+ $option = FLW_Settings::option_from_methods( array( 'mobilemoney' ) );
+
+ $this->assertStringContainsString( 'mobilemoneyghana', $option );
+ $this->assertStringContainsString( 'mpesa', $option );
+ }
+
+ public function test_legacy_all_option_round_trips() {
+ $legacy = FLW_Settings::LEGACY_METHODS['all'];
+
+ $this->assertSame( array( 'card', 'banktransfer', 'mobilemoney' ), FLW_Settings::methods_from_option( $legacy ) );
+ $this->assertSame( array( 'ussd', 'qr', 'credit', 'barter' ), FLW_Settings::advanced_from_option( $legacy ) );
+ }
+
+ /**
+ * @dataProvider legacy_methods
+ *
+ * @param mixed $stored Stored settings.
+ * @param string $expected Payment options sent to Flutterwave.
+ */
+ public function test_payment_options_fall_back_to_legacy_method( $stored, string $expected ) {
+ $this->option = $stored;
+
+ $this->assertSame( $expected, FLW_Settings::payment_options() );
+ }
+
+ /**
+ * Stored settings and the payment options they produce.
+ *
+ * @return array
+ */
+ public static function legacy_methods(): array {
+ return array(
+ 'nothing saved' => array( false, FLW_Settings::LEGACY_METHODS['all'] ),
+ 'legacy card only' => array( array( 'method' => 'card' ), 'card' ),
+ 'legacy both' => array( array( 'method' => 'both' ), 'card,account' ),
+ 'legacy unknown' => array( array( 'method' => 'nope' ), FLW_Settings::LEGACY_METHODS['all'] ),
+ 'new setting wins' => array(
+ array(
+ 'method' => 'card',
+ 'payment_options' => 'applepay',
+ ),
+ 'applepay',
+ ),
+ );
+ }
+
+ public function test_fresh_install_is_not_onboarded() {
+ $this->assertFalse( FLW_Settings::is_onboarded() );
+ }
+
+ public function test_upgraded_site_with_keys_counts_as_onboarded() {
+ $this->option = array( 'public_key' => 'FLWPUBK_TEST-x' );
+
+ $this->assertTrue( FLW_Settings::is_onboarded() );
+ }
+
+ public function test_payload_for_fresh_install() {
+ $payload = FLW_Settings::to_payload();
+
+ $this->assertFalse( $payload['onboardingComplete'] );
+ $this->assertSame( '', $payload['title'] );
+ $this->assertSame( 'https://shop.test/', $payload['successUrl'] );
+ $this->assertSame( 'https://shop.test/wp-json/flutterwave/v1/webhook', $payload['webhookUrl'] );
+ $this->assertSame( array( 'card', 'banktransfer', 'mobilemoney' ), $payload['paymentMethods'] );
+ $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $payload['secretHash'] );
+ $this->assertSame( $payload['secretHash'], $this->option['secret_hash'], 'The generated hash is stored so it survives a reload.' );
+ }
+
+ public function test_payload_maps_stored_values() {
+ $this->option = array(
+ 'modal_title' => 'Acme',
+ 'public_key' => 'FLWPUBK-live',
+ 'currency' => 'KES',
+ 'success_redirect_url' => 'https://shop.test/thanks',
+ 'theme_style' => 'yes',
+ 'secret_hash' => 'kept',
+ );
+
+ $payload = FLW_Settings::to_payload();
+
+ $this->assertSame( 'Acme', $payload['title'] );
+ $this->assertSame( 'live', $payload['mode'] );
+ $this->assertSame( 'KES', $payload['currency'] );
+ $this->assertSame( 'https://shop.test/thanks', $payload['successUrl'] );
+ $this->assertTrue( $payload['useThemeStyle'] );
+ $this->assertSame( 'kept', $payload['secretHash'] );
+ }
+
+ public function test_unknown_stored_currency_reads_as_unset() {
+ $this->option = array( 'currency' => 'BTC' );
+
+ $this->assertSame( '', FLW_Settings::to_payload()['currency'] );
+ }
+
+ public function test_update_only_touches_keys_in_the_patch() {
+ $this->option = array(
+ 'modal_title' => 'Old title',
+ 'public_key' => 'FLWPUBK_TEST-keep',
+ 'secret_hash' => 'keep',
+ );
+
+ FLW_Settings::update( array( 'title' => 'New title ' ) );
+
+ $this->assertSame( 'New title', $this->option['modal_title'] );
+ $this->assertSame( 'FLWPUBK_TEST-keep', $this->option['public_key'] );
+ $this->assertSame( 'keep', $this->option['secret_hash'] );
+ }
+
+ public function test_update_sanitises_every_value() {
+ FLW_Settings::update(
+ array(
+ 'successUrl' => 'javascript:alert(1)',
+ 'currency' => 'BTC',
+ 'secretKey' => " FLWSECK_TEST-x\n",
+ 'paymentMethods' => array( 'card', '