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..7fa5884
--- /dev/null
+++ b/client/admin/components/index.js
@@ -0,0 +1,297 @@
+/**
+ * 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.
+ * @return {Element} The page shell.
+ */
+export const Page = ( { backLabel, onBack, children, aside, wide } ) => (
+
+
+
+ { onBack ? (
+
+
+ { backLabel }
+
+ ) : (
+
+
+ { backLabel }
+
+ ) }
+
+
+
+
{ 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-woocommerce-payment-gateway' ) }{ ' ' }
+
+ { current } { __( 'of', 'rave-woocommerce-payment-gateway' ) } { 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-woocommerce-payment-gateway' )
+ : __( 'Copy', 'rave-woocommerce-payment-gateway' ) }
+
+
+ );
+};
+
+/**
+ * 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 }
+
+ ×
+
+
+);
diff --git a/client/admin/fonts/Inter-cyrillic-ext-normal.woff2 b/client/admin/fonts/Inter-cyrillic-ext-normal.woff2
new file mode 100644
index 0000000..405017d
Binary files /dev/null and b/client/admin/fonts/Inter-cyrillic-ext-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-cyrillic-normal.woff2 b/client/admin/fonts/Inter-cyrillic-normal.woff2
new file mode 100644
index 0000000..f57ab2f
Binary files /dev/null and b/client/admin/fonts/Inter-cyrillic-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-greek-ext-normal.woff2 b/client/admin/fonts/Inter-greek-ext-normal.woff2
new file mode 100644
index 0000000..2fc07e9
Binary files /dev/null and b/client/admin/fonts/Inter-greek-ext-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-greek-normal.woff2 b/client/admin/fonts/Inter-greek-normal.woff2
new file mode 100644
index 0000000..41576da
Binary files /dev/null and b/client/admin/fonts/Inter-greek-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-latin-ext-normal.woff2 b/client/admin/fonts/Inter-latin-ext-normal.woff2
new file mode 100644
index 0000000..fb2522c
Binary files /dev/null and b/client/admin/fonts/Inter-latin-ext-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-latin-normal.woff2 b/client/admin/fonts/Inter-latin-normal.woff2
new file mode 100644
index 0000000..937631c
Binary files /dev/null and b/client/admin/fonts/Inter-latin-normal.woff2 differ
diff --git a/client/admin/fonts/Inter-vietnamese-normal.woff2 b/client/admin/fonts/Inter-vietnamese-normal.woff2
new file mode 100644
index 0000000..e905732
Binary files /dev/null and b/client/admin/fonts/Inter-vietnamese-normal.woff2 differ
diff --git a/client/admin/fonts/Millik.woff2 b/client/admin/fonts/Millik.woff2
new file mode 100644
index 0000000..ce3671b
Binary files /dev/null and b/client/admin/fonts/Millik.woff2 differ
diff --git a/client/admin/fonts/Moderat-Bold.woff2 b/client/admin/fonts/Moderat-Bold.woff2
new file mode 100644
index 0000000..f44b8a3
Binary files /dev/null and b/client/admin/fonts/Moderat-Bold.woff2 differ
diff --git a/client/admin/fonts/Moderat-Medium.woff2 b/client/admin/fonts/Moderat-Medium.woff2
new file mode 100644
index 0000000..d655c1a
Binary files /dev/null and b/client/admin/fonts/Moderat-Medium.woff2 differ
diff --git a/client/admin/fonts/Moderat-Regular.woff2 b/client/admin/fonts/Moderat-Regular.woff2
new file mode 100644
index 0000000..dc9dd31
Binary files /dev/null and b/client/admin/fonts/Moderat-Regular.woff2 differ
diff --git a/client/admin/icons.js b/client/admin/icons.js
new file mode 100644
index 0000000..a26f174
--- /dev/null
+++ b/client/admin/icons.js
@@ -0,0 +1,195 @@
+/**
+ * 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 );
+
+export const FlutterwaveWordmark = () =>
+ svg(
+ { viewBox: '0 0 200 32', className: 'flw-wordmark', role: 'img', 'aria-label': 'Flutterwave' },
+ el( 'path', {
+ d: 'M14.8 4.2c4.6-2.6 10.2-.9 12.6 3.7 2.3 4.6.5 10.2-4 12.7l-9.9 5.5c-4.6 2.6-10.2.9-12.6-3.7C-1.4 17.8.4 12.2 5 9.7l9.8-5.5Z',
+ fill: '#F5A623',
+ } ),
+ el( 'path', {
+ d: 'M5.2 4.2C9.8 1.6 15.4 3.3 17.8 7.9c2.3 4.6.5 10.2-4 12.7-4.6 2.6-10.2.9-12.6-3.7C-1.1 12.3.7 6.7 5.2 4.2Z',
+ fill: '#F04E23',
+ opacity: '0.85',
+ } ),
+ el(
+ 'text',
+ { x: '40', y: '23', fill: '#2B1A0F', fontFamily: 'Georgia, serif', fontSize: '22', fontWeight: '600' },
+ 'flutterwave'
+ )
+ );
+
+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'
+ )
+ ),
+};
diff --git a/client/admin/index.js b/client/admin/index.js
new file mode 100644
index 0000000..4f00edd
--- /dev/null
+++ b/client/admin/index.js
@@ -0,0 +1,23 @@
+/**
+ * Mounts the Flutterwave admin app.
+ */
+
+import { createRoot } from '@wordpress/element';
+import App from './app';
+import './style.scss';
+
+const mount = () => {
+ const root = document.getElementById( 'flutterwave-admin-root' );
+
+ if ( ! root ) {
+ return;
+ }
+
+ 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..2607220
--- /dev/null
+++ b/client/admin/lib/api.js
@@ -0,0 +1,34 @@
+/**
+ * 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 || {};
diff --git a/client/admin/lib/constants.js b/client/admin/lib/constants.js
new file mode 100644
index 0000000..ca9e4e1
--- /dev/null
+++ b/client/admin/lib/constants.js
@@ -0,0 +1,117 @@
+/**
+ * Shared copy and option lists for the admin app.
+ */
+
+import { __ } from '@wordpress/i18n';
+
+export const STEPS = [
+ { key: 'general', label: __( 'General details', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'api', label: __( 'API & webhook', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'methods', label: __( 'Payment methods', 'rave-woocommerce-payment-gateway' ) },
+];
+
+export const CHECKOUT_OPTIONS = [
+ {
+ value: 'inline',
+ label: __( 'Open payment interface in a pop-up.', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ value: 'redirect',
+ label: __( 'Redirect users to the payment page in a new tab.', 'rave-woocommerce-payment-gateway' ),
+ },
+];
+
+/**
+ * The checkbox list on the "Payment methods" step. `key` maps to the groups
+ * declared in Flutterwave_Settings::METHOD_MAP on the PHP side.
+ */
+export const PAYMENT_METHODS = [
+ {
+ key: 'card',
+ label: __( 'Cards (credit/debit cards)', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Pay securely using your Visa, Mastercard, or American Express.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'stablecoin',
+ label: __( 'Stablecoin', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Pay with USDT or USDC. Fast, global, and low-fee.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'banktransfer',
+ label: __( 'Bank Transfer', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ "Transfer funds directly from your bank account using your bank's transfer service.",
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'mobilemoney',
+ label: __( 'Mobile Money', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Quick and secure payment directly from your mobile wallet.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'applepay',
+ label: __( 'Apple Pay', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Check out in seconds using your Apple Pay wallet.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'googlepay',
+ label: __( 'Google Pay', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Simple and fast checkout using the cards saved to your Google Account.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+ {
+ key: 'opay',
+ label: __( 'Opay', 'rave-woocommerce-payment-gateway' ),
+ description: __(
+ 'Pay directly from your OPay wallet or card.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ },
+];
+
+/**
+ * Flutterwave payment options the designs do not surface as checkboxes. Kept
+ * editable under "Advanced" so upgrading merchants never silently lose them.
+ */
+export const ADVANCED_METHODS = [
+ { key: 'ussd', label: __( 'USSD', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'qr', label: __( 'QR', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'nqr', label: __( 'NQR', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'credit', label: __( 'Credit', 'rave-woocommerce-payment-gateway' ) },
+ { key: 'barter', label: __( 'Barter', 'rave-woocommerce-payment-gateway' ) },
+];
+
+export const TOOLTIPS = {
+ title: __( 'Your unique payment modal title.', 'rave-woocommerce-payment-gateway' ),
+ testPublicKey: __(
+ 'This is your test API key. Retrieve this from your test mode dashboard.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ testSecretKey: __(
+ 'This is your test private key. Retrieve this from your test mode dashboard.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ livePublicKey: __(
+ 'This is your production public key for live transactions.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+ liveSecretKey: __(
+ 'This is your production private key for live transactions.',
+ 'rave-woocommerce-payment-gateway'
+ ),
+};
diff --git a/client/admin/lib/validate.js b/client/admin/lib/validate.js
new file mode 100644
index 0000000..c49efb6
--- /dev/null
+++ b/client/admin/lib/validate.js
@@ -0,0 +1,82 @@
+/**
+ * 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();
+
+/**
+ * Step 1 needs a checkout title and a checkout style.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateGeneral = ( values ) => {
+ const errors = {};
+
+ if ( blank( values.title ) ) {
+ errors.title = __( 'Enter a title shoppers will see at checkout.', 'rave-woocommerce-payment-gateway' );
+ }
+
+ if ( blank( values.paymentStyle ) ) {
+ errors.paymentStyle = __( 'Choose how the payment interface opens.', 'rave-woocommerce-payment-gateway' );
+ }
+
+ return errors;
+};
+
+/**
+ * Step 2 needs the key pair for whichever mode is active. Live mode is checked
+ * only when the merchant has switched it on, so test-only setups still pass.
+ *
+ * @param {Object} values Form values.
+ * @return {Object} Field errors.
+ */
+export const validateApi = ( values ) => {
+ const errors = {};
+
+ if ( blank( values.testPublicKey ) ) {
+ errors.testPublicKey = __( 'Enter your test public key.', 'rave-woocommerce-payment-gateway' );
+ }
+
+ if ( blank( values.testSecretKey ) ) {
+ errors.testSecretKey = __( 'Enter your test private key.', 'rave-woocommerce-payment-gateway' );
+ }
+
+ if ( values.goLive ) {
+ if ( blank( values.livePublicKey ) ) {
+ errors.livePublicKey = __(
+ 'Live transactions are enabled, so a live public key is required.',
+ 'rave-woocommerce-payment-gateway'
+ );
+ }
+
+ if ( blank( values.liveSecretKey ) ) {
+ errors.liveSecretKey = __(
+ 'Live transactions are enabled, so a live private key is required.',
+ 'rave-woocommerce-payment-gateway'
+ );
+ }
+ }
+
+ 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-woocommerce-payment-gateway' );
+ }
+
+ return errors;
+};
diff --git a/client/admin/screens/Settings.js b/client/admin/screens/Settings.js
new file mode 100644
index 0000000..b964b7b
--- /dev/null
+++ b/client/admin/screens/Settings.js
@@ -0,0 +1,141 @@
+/**
+ * Post-onboarding screen: the same three groups of fields, 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 { GeneralForm, ApiForm, MethodsForm } from './forms';
+import { saveSettings } from '../lib/api';
+import { validateGeneral, validateApi, validateMethods } from '../lib/validate';
+
+const TABS = [
+ {
+ key: 'general',
+ label: __( 'General', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'Flutterwave Payment', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'title', 'description', 'paymentStyle', 'autocompleteOrder' ],
+ validate: validateGeneral,
+ Form: GeneralForm,
+ cta: __( 'Save & continue', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ key: 'api',
+ label: __( 'API & Webhook', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'General', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'testPublicKey', 'testSecretKey', 'livePublicKey', 'liveSecretKey', 'secretHash', 'goLive' ],
+ validate: validateApi,
+ Form: ApiForm,
+ cta: __( 'Save & continue', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ key: 'methods',
+ label: __( 'Payment Methods', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'API & Webhook', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'paymentMethods', 'advancedMethods', 'disableLogging', 'disableBarter' ],
+ validate: validateMethods,
+ Form: MethodsForm,
+ cta: __( 'Save', 'rave-woocommerce-payment-gateway' ),
+ },
+];
+
+/**
+ * The tab named by `?tab=` in the URL (the secret hash admin notice links to
+ * `tab=api`), 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 );
+ const [ busy, setBusy ] = useState( false );
+ const [ errors, setErrors ] = useState( {} );
+
+ const tab = TABS[ active ];
+ const { Form } = tab;
+
+ const save = async () => {
+ const found = tab.validate( values );
+
+ if ( Object.keys( found ).length ) {
+ setErrors( found );
+ return;
+ }
+
+ setErrors( {} );
+ 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-woocommerce-payment-gateway' ) );
+
+ if ( active < TABS.length - 1 ) {
+ setActive( active + 1 );
+ }
+ } catch ( error ) {
+ onNotice(
+ 'error',
+ error?.message || __( 'Could not save your settings. Please try again.', 'rave-woocommerce-payment-gateway' )
+ );
+ } finally {
+ setBusy( false );
+ }
+ };
+
+ return (
+ 0 ? () => setActive( active - 1 ) : undefined }
+ >
+
+ { TABS.map( ( item, index ) => (
+ {
+ setErrors( {} );
+ setActive( 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..9dc4072
--- /dev/null
+++ b/client/admin/screens/Success.js
@@ -0,0 +1,38 @@
+/**
+ * Terminal screen of the wizard. Auto-advances to the settings view so the
+ * merchant is not left on a dead end.
+ */
+
+import { useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { SuccessIllustration } from '../icons';
+
+/**
+ * @param {Object} props
+ * @param {Function} props.onDone Called once the confirmation has been shown.
+ * @return {Element} The success screen.
+ */
+const Success = ( { onDone } ) => {
+ useEffect( () => {
+ const timer = setTimeout( onDone, 2600 );
+ return () => clearTimeout( timer );
+ }, [ onDone ] );
+
+ return (
+
+
+
{ __( 'Store setup successful!', 'rave-woocommerce-payment-gateway' ) }
+
+ { __(
+ 'You have successfully connected your Flutterwave for Business account to WooCommerce.',
+ 'rave-woocommerce-payment-gateway'
+ ) }
+
+
+ { __( 'Go to settings', 'rave-woocommerce-payment-gateway' ) } →
+
+
+ );
+};
+
+export default Success;
diff --git a/client/admin/screens/Welcome.js b/client/admin/screens/Welcome.js
new file mode 100644
index 0000000..425d2a1
--- /dev/null
+++ b/client/admin/screens/Welcome.js
@@ -0,0 +1,79 @@
+/**
+ * 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-woocommerce-payment-gateway' ) }
+
+ { __( 'for Business', 'rave-woocommerce-payment-gateway' ) }
+
+
+
+ { __(
+ 'Flutterwave enables you to collect payments in over 30 currencies worldwide. With support for Cards, Mobile money, Bank transfer, Apple Pay, Google Pay and more.',
+ 'rave-woocommerce-payment-gateway'
+ ) }
+
+
+
+ { __( 'Activate Flutterwave', 'rave-woocommerce-payment-gateway' ) }
+
+
+
+
+
+ );
+};
+
+export default Welcome;
diff --git a/client/admin/screens/Wizard.js b/client/admin/screens/Wizard.js
new file mode 100644
index 0000000..73cd7a5
--- /dev/null
+++ b/client/admin/screens/Wizard.js
@@ -0,0 +1,134 @@
+/**
+ * The three-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 and
+ * enables the gateway.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Page, StepBadge, Stepper, Button, LoadingScreen } from '../components';
+import { GeneralForm, ApiForm, MethodsForm } from './forms';
+import { STEPS } from '../lib/constants';
+import { saveSettings, completeOnboarding } from '../lib/api';
+import { validateGeneral, validateApi, validateMethods } from '../lib/validate';
+
+const STEP_META = [
+ {
+ title: __( 'General details', 'rave-woocommerce-payment-gateway' ),
+ subtitle: __( 'Please fill in your correct details to setup your store', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'Flutterwave Payment', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'title', 'description', 'paymentStyle', 'autocompleteOrder' ],
+ validate: validateGeneral,
+ Form: GeneralForm,
+ },
+ {
+ title: __( 'API & webhook', 'rave-woocommerce-payment-gateway' ),
+ subtitle: __( 'Please fill in your correct details to setup your store', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'General', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'testPublicKey', 'testSecretKey', 'livePublicKey', 'liveSecretKey', 'secretHash', 'goLive' ],
+ validate: validateApi,
+ Form: ApiForm,
+ },
+ {
+ title: __( 'Payment methods', 'rave-woocommerce-payment-gateway' ),
+ subtitle: __( 'Add your preferred methods to your checkout.', 'rave-woocommerce-payment-gateway' ),
+ crumb: __( 'API & Webhook', 'rave-woocommerce-payment-gateway' ),
+ fields: [ 'paymentMethods', 'advancedMethods', 'disableLogging', 'disableBarter' ],
+ validate: validateMethods,
+ Form: MethodsForm,
+ },
+];
+
+/**
+ * @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 [ errors, setErrors ] = useState( {} );
+
+ const meta = STEP_META[ index ];
+ const { Form } = meta;
+ const isLast = index === STEP_META.length - 1;
+
+ // Mirrors the designs: the primary button stays faded until the step is
+ // actually completable.
+ const complete = Object.keys( meta.validate( values ) ).length === 0;
+
+ const patchFor = ( step ) =>
+ step.fields.reduce( ( patch, key ) => ( { ...patch, [ key ]: values[ key ] } ), {} );
+
+ const advance = async () => {
+ const found = meta.validate( values );
+
+ if ( Object.keys( found ).length ) {
+ setErrors( found );
+ return;
+ }
+
+ setErrors( {} );
+ 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 );
+ setIndex( index + 1 );
+ }
+ } catch ( error ) {
+ setSubmitting( false );
+ onError( error?.message || __( 'Could not save your settings. Please try again.', 'rave-woocommerce-payment-gateway' ) );
+ } finally {
+ setBusy( false );
+ }
+ };
+
+ if ( submitting ) {
+ return ;
+ }
+
+ return (
+ 0 ? () => setIndex( index - 1 ) : undefined }
+ aside={ }
+ >
+
+
+ { meta.title }
+ { meta.subtitle }
+
+
+
+
+ 0 ? 'has-back' : '' }` }>
+ { index > 0 && (
+ setIndex( index - 1 ) } disabled={ busy }>
+ { __( 'Go back', 'rave-woocommerce-payment-gateway' ) }
+
+ ) }
+
+ { isLast
+ ? __( 'Save', 'rave-woocommerce-payment-gateway' )
+ : __( 'Save & continue', 'rave-woocommerce-payment-gateway' ) }
+
+
+
+ );
+};
+
+export default Wizard;
diff --git a/client/admin/screens/forms.js b/client/admin/screens/forms.js
new file mode 100644
index 0000000..c994d12
--- /dev/null
+++ b/client/admin/screens/forms.js
@@ -0,0 +1,317 @@
+/**
+ * The three 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,
+} from '../components';
+import { METHOD_MARKS } from '../icons';
+import {
+ CHECKOUT_OPTIONS,
+ PAYMENT_METHODS,
+ ADVANCED_METHODS,
+ TOOLTIPS,
+} from '../lib/constants';
+
+/**
+ * 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 — payment method title, description, checkout style, autocomplete.
+ *
+ * @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( 'paymentStyle', event.target.value ) }
+ />
+
+
+ setValue( 'autocompleteOrder', checked ) }
+ label={ __( 'Auto complete order', 'rave-woocommerce-payment-gateway' ) }
+ />
+ >
+);
+
+/**
+ * Step 2 — API keys, the webhook URL and secret hash to copy, and the
+ * live-mode switch.
+ *
+ * @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 [ regenerated, setRegenerated ] = useState( false );
+
+ const keyFields = [
+ {
+ key: 'testPublicKey',
+ label: __( 'Test public key', 'rave-woocommerce-payment-gateway' ),
+ placeholder: __( 'Enter your test public key', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ key: 'testSecretKey',
+ label: __( 'Test private key', 'rave-woocommerce-payment-gateway' ),
+ placeholder: __( 'Enter your test private key', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ key: 'livePublicKey',
+ label: __( 'Live public key', 'rave-woocommerce-payment-gateway' ),
+ placeholder: __( 'Enter your live public key', 'rave-woocommerce-payment-gateway' ),
+ },
+ {
+ key: 'liveSecretKey',
+ label: __( 'Live private key', 'rave-woocommerce-payment-gateway' ),
+ placeholder: __( 'Enter your live private key', 'rave-woocommerce-payment-gateway' ),
+ },
+ ];
+
+ return (
+ <>
+ { keyFields.map( ( field ) => (
+
+ setValue( field.key, event.target.value ) }
+ />
+
+ ) ) }
+
+
+
+
+
+
+
+ setValue( 'secretHash', event.target.value ) }
+ />
+ {
+ setValue( 'secretHash', generateSecretHash() );
+ setRegenerated( true );
+ } }
+ >
+ { __( 'Generate', 'rave-woocommerce-payment-gateway' ) }
+
+
+
+
+ setValue( 'goLive', checked ) }
+ label={ __( 'Enable live transactions', 'rave-woocommerce-payment-gateway' ) }
+ />
+ >
+ );
+};
+
+/**
+ * Step 3 — the payment methods offered at checkout, plus the advanced options
+ * the designs drop but that 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-woocommerce-payment-gateway' ) }
+
+
+
+ { advancedOpen && (
+
+
+ { __(
+ 'Additional Flutterwave payment options and diagnostics. These are not shown at checkout unless selected.',
+ 'rave-woocommerce-payment-gateway'
+ ) }
+
+
+
+ { ADVANCED_METHODS.map( ( method ) => (
+ toggle( 'advancedMethods', method.key, checked ) }
+ label={ method.label }
+ />
+ ) ) }
+
+
+
setValue( 'disableLogging', checked ) }
+ label={ __( 'Disable logging', 'rave-woocommerce-payment-gateway' ) }
+ />
+ setValue( 'disableBarter', checked ) }
+ label={ __( 'Disable Barter', 'rave-woocommerce-payment-gateway' ) }
+ />
+
+ ) }
+
+ >
+ );
+};
diff --git a/client/admin/style.scss b/client/admin/style.scss
new file mode 100644
index 0000000..28d25a7
--- /dev/null
+++ b/client/admin/style.scss
@@ -0,0 +1,916 @@
+@use './fonts';
+
+/**
+ * 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.
+ */
+
+$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: #ffffff;
+$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 {
+ max-width: none;
+
+ &:focus {
+ border-color: $flw-orange;
+ box-shadow: 0 0 0 3px rgba(255, 155, 0, 0.18);
+ outline: none;
+ }
+}
+
+.flw-admin-root select.flw-input {
+ height: auto;
+ line-height: 1.4;
+ min-height: 0;
+}
+
+.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;
+ }
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* 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 {
+ height: 32px;
+ width: 200px;
+ }
+
+ &__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;
+}
+
+/* -------------------------------------------------------------------------- */
+/* 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);
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* 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-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/composer.json b/composer.json
index 35fff07..7e147c5 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,8 @@
"phpcompatibility/phpcompatibility-wp": "^2.1",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"woocommerce/woocommerce-sniffs": "^1.0",
- "yoast/phpunit-polyfills": "^2.0"
+ "yoast/phpunit-polyfills": "^2.0",
+ "woocommerce/qit-cli": "^1.3"
},
"config": {
"allow-plugins": {
diff --git a/composer.lock b/composer.lock
index a4c2e55..4d311a9 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "d91db0a173ec46eec1f45a7791a0d28c",
+ "content-hash": "2b62291bc9289391bb984c30ed73265d",
"packages": [],
"packages-dev": [
{
@@ -2350,6 +2350,39 @@
],
"time": "2025-11-17T20:03:58+00:00"
},
+ {
+ "name": "woocommerce/qit-cli",
+ "version": "1.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/woocommerce/qit-cli.git",
+ "reference": "78c1b047506cdd0b36a177c17e2bb616a001858e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/woocommerce/qit-cli/zipball/78c1b047506cdd0b36a177c17e2bb616a001858e",
+ "reference": "78c1b047506cdd0b36a177c17e2bb616a001858e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "php": "^7.4 | ^8"
+ },
+ "bin": [
+ "qit"
+ ],
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-3.0-or-later"
+ ],
+ "description": "A command line interface for WooCommerce Quality Insights Toolkit (QIT).",
+ "support": {
+ "issues": "https://github.com/woocommerce/qit-cli/issues",
+ "source": "https://github.com/woocommerce/qit-cli/tree/1.3.2"
+ },
+ "time": "2026-09-16T12:39:39+00:00"
+ },
{
"name": "woocommerce/woocommerce-sniffs",
"version": "1.0.1",
diff --git a/i18n/languages/rave-woocommerce-payment-gateway.pot b/i18n/languages/rave-woocommerce-payment-gateway.pot
index 5cbc8a7..3360c2e 100644
--- a/i18n/languages/rave-woocommerce-payment-gateway.pot
+++ b/i18n/languages/rave-woocommerce-payment-gateway.pot
@@ -2,14 +2,14 @@
# This file is distributed under the MIT License.
msgid ""
msgstr ""
-"Project-Id-Version: Flutterwave WooCommerce 3.2.0\n"
+"Project-Id-Version: Flutterwave WooCommerce 3.3.1\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/rave-woocommerce-payment-gateway\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: 2026-07-20T20:31:48+00:00\n"
+"POT-Creation-Date: 2026-09-23T12:13:39+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: rave-woocommerce-payment-gateway\n"
@@ -42,6 +42,14 @@ msgstr ""
msgid "http://flutterwave.com/us"
msgstr ""
+#: includes/admin/class-flutterwave-admin-page.php:86
+#: includes/admin/class-flutterwave-admin-page.php:87
+#: client/admin/screens/Settings.js:19
+#: client/admin/screens/Welcome.js:20
+#: client/admin/screens/Wizard.js:21
+msgid "Flutterwave Payment"
+msgstr ""
+
#: includes/blocks/class-flutterwave-wc-gateway-blocks-support.php:155
msgid "Visa"
msgstr ""
@@ -67,82 +75,86 @@ msgstr ""
msgid "Diners"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:66
+#: includes/class-flw-wc-payment-gateway-event-handler.php:68
msgid "Payment initialized via Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:68
+#: includes/class-flw-wc-payment-gateway-event-handler.php:71
msgid "Your transaction reference: "
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:85
+#: includes/class-flw-wc-payment-gateway-event-handler.php:88
msgid "Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it."
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:86
+#: includes/class-flw-wc-payment-gateway-event-handler.php:89
msgid "Amount paid: "
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:86
+#: includes/class-flw-wc-payment-gateway-event-handler.php:89
msgid "Order amount: "
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:86
+#: includes/class-flw-wc-payment-gateway-event-handler.php:89
msgid " Reference: "
msgstr ""
#. translators: 1: payment reference 2: transaction reference
-#: includes/class-flw-wc-payment-gateway-event-handler.php:97
-#: includes/class-flw-wc-payment-gateway-subscriptions.php:163
+#: includes/class-flw-wc-payment-gateway-event-handler.php:100
+#: includes/class-flw-wc-payment-gateway-subscriptions.php:165
#, php-format
msgid "Payment via Flutterwave successful (Payment Reference: %1$s, Transaction Reference: %2$s)"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:145
+#: includes/class-flw-wc-payment-gateway-event-handler.php:151
msgid "The payment failed on Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:150
+#: includes/class-flw-wc-payment-gateway-event-handler.php:162
msgid "Reason for Failure : "
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:164
+#: includes/class-flw-wc-payment-gateway-event-handler.php:180
msgid "Confirming payment on Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:174
+#: includes/class-flw-wc-payment-gateway-event-handler.php:190
msgid "An error occured while confirming payment on Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:179
+#: includes/class-flw-wc-payment-gateway-event-handler.php:195
msgid "Attention: New order has been placed on hold because we could not confirm the payment. Please, look into it."
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:196
+#: includes/class-flw-wc-payment-gateway-event-handler.php:215
msgid "The customer clicked on the cancel button on Checkout."
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:198
+#: includes/class-flw-wc-payment-gateway-event-handler.php:219
+msgid "The order was not cancelled because it is no longer awaiting payment."
+msgstr ""
+
+#: includes/class-flw-wc-payment-gateway-event-handler.php:225
msgid "Attention: Customer clicked on the cancel button on the payment gateway. We have updated the order to cancelled status. "
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:199
+#: includes/class-flw-wc-payment-gateway-event-handler.php:226
msgid "Please, confirm from the order notes that there is no note of a successful transaction. If there is, this means that the user was debited and you either have to give value for the transaction or refund the customer."
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:213
+#: includes/class-flw-wc-payment-gateway-event-handler.php:240
msgid "The payment didn't return a valid response. It could have timed out or abandoned by the customer on Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:218
+#: includes/class-flw-wc-payment-gateway-event-handler.php:245
msgid "Attention: New order has been placed on hold because we could not get a definite response from the payment gateway. Kindly contact the Flutterwave support team at hi@flutterwave.com to confirm the payment."
msgstr ""
-#: includes/class-flw-wc-payment-gateway-event-handler.php:219
+#: includes/class-flw-wc-payment-gateway-event-handler.php:246
msgid "Payment Reference: "
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:164
+#: includes/class-flw-wc-payment-gateway.php:167
msgid "allows you to accept payment from cards and bank accounts in multiple currencies. You can also accept payment offline via USSD and POS."
msgstr ""
@@ -154,186 +166,187 @@ msgstr ""
msgid "Please copy this webhook URL and paste on the webhook section on your dashboard"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:314
+#: includes/class-flw-wc-payment-gateway.php:313
msgid "Enable/Disable"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:315
+#: includes/class-flw-wc-payment-gateway.php:314
msgid "Enable Flutterwave"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:317
+#: includes/class-flw-wc-payment-gateway.php:316
msgid "Enable Flutterwave as a payment option on the checkout page"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:322
+#: includes/class-flw-wc-payment-gateway.php:321
msgid "Enter Secret Hash"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:324
+#: includes/class-flw-wc-payment-gateway.php:323
msgid "Please change from default hash and ensure that SECRET HASH is the same with the one on your Flutterwave dashboard"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:328
+#: includes/class-flw-wc-payment-gateway.php:327
msgid "Payment method title"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:330
-#: includes/class-flw-wc-payment-gateway.php:336
+#: includes/class-flw-wc-payment-gateway.php:329
+#: includes/class-flw-wc-payment-gateway.php:335
msgid "Optional"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:334
+#: includes/class-flw-wc-payment-gateway.php:333
msgid "Payment method description"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:340
+#: includes/class-flw-wc-payment-gateway.php:339
msgid "Test Public Key"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:342
+#: includes/class-flw-wc-payment-gateway.php:341
msgid "Required! Enter your Flutterwave test public key here"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:346
+#: includes/class-flw-wc-payment-gateway.php:345
msgid "Test Secret Key"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:348
+#: includes/class-flw-wc-payment-gateway.php:347
msgid "Required! Enter your Flutterwave test secret key here"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:352
+#: includes/class-flw-wc-payment-gateway.php:351
msgid "Live Public Key"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:354
+#: includes/class-flw-wc-payment-gateway.php:353
msgid "Required! Enter your Flutterwave live public key here"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:358
+#: includes/class-flw-wc-payment-gateway.php:357
msgid "Live Secret Key"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:360
+#: includes/class-flw-wc-payment-gateway.php:359
msgid "Required! Enter your Flutterwave live secret key here"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:364
+#: includes/class-flw-wc-payment-gateway.php:363
msgid "Payment Style on checkout"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:366
+#: includes/class-flw-wc-payment-gateway.php:365
msgid "Optional - Choice of payment style to use. Either inline or redirect. (Default: inline)"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:368
+#: includes/class-flw-wc-payment-gateway.php:367
msgctxt "payment_style"
msgid "Popup(Keep payment experience on the website)"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:369
+#: includes/class-flw-wc-payment-gateway.php:368
msgctxt "payment_style"
msgid "Redirect"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:374
+#: includes/class-flw-wc-payment-gateway.php:373
msgid "Autocomplete Order After Payment"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:375
+#: includes/class-flw-wc-payment-gateway.php:374
msgid "Autocomplete Order"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:378
+#: includes/class-flw-wc-payment-gateway.php:377
msgid "If enabled, the order will be marked as complete after successful payment"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:383
+#: includes/class-flw-wc-payment-gateway.php:382
msgid "Payment Options"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:385
+#: includes/class-flw-wc-payment-gateway.php:384
msgid "Optional - Choice of payment method to use. Card, Account etc."
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:387
+#: includes/class-flw-wc-payment-gateway.php:386
msgctxt "payment_options"
msgid "All"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:388
+#: includes/class-flw-wc-payment-gateway.php:387
msgctxt "payment_options"
msgid "Card Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:389
+#: includes/class-flw-wc-payment-gateway.php:388
msgctxt "payment_options"
msgid "Account Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:390
+#: includes/class-flw-wc-payment-gateway.php:389
msgctxt "payment_options"
msgid "USSD Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:391
+#: includes/class-flw-wc-payment-gateway.php:390
msgctxt "payment_options"
msgid "QR Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:392
+#: includes/class-flw-wc-payment-gateway.php:391
msgctxt "payment_options"
msgid "Mpesa Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:393
+#: includes/class-flw-wc-payment-gateway.php:392
msgctxt "payment_options"
msgid "Ghana MM Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:394
+#: includes/class-flw-wc-payment-gateway.php:393
msgctxt "payment_options"
msgid "Rwanda MM Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:395
+#: includes/class-flw-wc-payment-gateway.php:394
msgctxt "payment_options"
msgid "Zambia MM Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:396
+#: includes/class-flw-wc-payment-gateway.php:395
msgctxt "payment_options"
msgid "Tanzania MM Only"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:401
+#: includes/class-flw-wc-payment-gateway.php:400
msgid "Mode"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:402
+#: includes/class-flw-wc-payment-gateway.php:401
msgid "Live mode"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:404
+#: includes/class-flw-wc-payment-gateway.php:403
msgid "Check this box if you're using your live keys."
msgstr ""
+#: includes/class-flw-wc-payment-gateway.php:408
#: includes/class-flw-wc-payment-gateway.php:409
-#: includes/class-flw-wc-payment-gateway.php:410
msgid "Disable Logging"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:412
+#: includes/class-flw-wc-payment-gateway.php:411
msgid "Check this box if you're disabling logging."
msgstr ""
+#: includes/class-flw-wc-payment-gateway.php:416
#: includes/class-flw-wc-payment-gateway.php:417
-#: includes/class-flw-wc-payment-gateway.php:418
+#: client/admin/screens/forms.js:275
msgid "Disable Barter"
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:420
+#: includes/class-flw-wc-payment-gateway.php:419
msgid "Check the box if you want to disable barter."
msgstr ""
@@ -343,21 +356,19 @@ msgstr ""
msgid "Flutterwave is enabled, but the API keys are not set. Please set your Flutterwave API keys to be able to accept payments."
msgstr ""
-#. translators: %s: shop cart url
-#: includes/class-flw-wc-payment-gateway.php:559
-#, php-format
-msgid "Sorry, your session has expired. Return to shop "
-msgstr ""
-
-#: includes/class-flw-wc-payment-gateway.php:573
+#: includes/class-flw-wc-payment-gateway.php:570
msgid "We were unable to process your order, please try again."
msgstr ""
-#: includes/class-flw-wc-payment-gateway.php:654
+#: includes/class-flw-wc-payment-gateway.php:619
msgid "Order Payment"
msgstr ""
-#: includes/client/class-flw-wc-payment-gateway-request.php:153
+#: includes/class-flw-wc-payment-gateway.php:877
+msgid "A successful Flutterwave charge arrived for this cancelled order. Confirming it with Flutterwave before reopening the order."
+msgstr ""
+
+#: includes/client/class-flw-wc-payment-gateway-request.php:135
msgid "Payment for order "
msgstr ""
@@ -386,34 +397,387 @@ msgstr ""
msgid "Install WooCommerce"
msgstr ""
-#: build/index.js:1
+#: client/admin/app.js:50
+msgid "Could not load your Flutterwave settings."
+msgstr ""
+
+#. translators: 1: current step number, 2: total number of steps.
+#: client/admin/components/index.js:57
+msgid "Step"
+msgstr ""
+
+#: client/admin/components/index.js:59
+msgid "of"
+msgstr ""
+
+#: client/admin/components/index.js:257
+msgid "Copied"
+msgstr ""
+
+#: client/admin/components/index.js:258
+msgid "Copy"
+msgstr ""
+
+#: client/admin/components/index.js:272
+msgid "Loading"
+msgstr ""
+
+#: client/admin/components/index.js:293
+msgid "Dismiss"
+msgstr ""
+
+#: client/admin/lib/constants.js:8
+#: client/admin/screens/Wizard.js:19
+msgid "General details"
+msgstr ""
+
+#: client/admin/lib/constants.js:9
+#: client/admin/screens/Wizard.js:27
+msgid "API & webhook"
+msgstr ""
+
+#: client/admin/lib/constants.js:10
+#: client/admin/screens/Wizard.js:35
+msgid "Payment methods"
+msgstr ""
+
+#: client/admin/lib/constants.js:16
+msgid "Open payment interface in a pop-up."
+msgstr ""
+
+#: client/admin/lib/constants.js:20
+msgid "Redirect users to the payment page in a new tab."
+msgstr ""
+
+#: client/admin/lib/constants.js:31
+msgid "Cards (credit/debit cards)"
+msgstr ""
+
+#: client/admin/lib/constants.js:32
+msgid "Pay securely using your Visa, Mastercard, or American Express."
+msgstr ""
+
+#: client/admin/lib/constants.js:39
+msgid "Stablecoin"
+msgstr ""
+
+#: client/admin/lib/constants.js:40
+msgid "Pay with USDT or USDC. Fast, global, and low-fee."
+msgstr ""
+
+#: client/admin/lib/constants.js:47
+msgid "Bank Transfer"
+msgstr ""
+
+#: client/admin/lib/constants.js:48
+msgid "Transfer funds directly from your bank account using your bank's transfer service."
+msgstr ""
+
+#: client/admin/lib/constants.js:55
+msgid "Mobile Money"
+msgstr ""
+
+#: client/admin/lib/constants.js:56
+msgid "Quick and secure payment directly from your mobile wallet."
+msgstr ""
+
+#: client/admin/lib/constants.js:63
+msgid "Apple Pay"
+msgstr ""
+
+#: client/admin/lib/constants.js:64
+msgid "Check out in seconds using your Apple Pay wallet."
+msgstr ""
+
+#: client/admin/lib/constants.js:71
+msgid "Google Pay"
+msgstr ""
+
+#: client/admin/lib/constants.js:72
+msgid "Simple and fast checkout using the cards saved to your Google Account."
+msgstr ""
+
+#: client/admin/lib/constants.js:79
+msgid "Opay"
+msgstr ""
+
+#: client/admin/lib/constants.js:80
+msgid "Pay directly from your OPay wallet or card."
+msgstr ""
+
+#: client/admin/lib/constants.js:92
+msgid "USSD"
+msgstr ""
+
+#: client/admin/lib/constants.js:93
+msgid "QR"
+msgstr ""
+
+#: client/admin/lib/constants.js:94
+msgid "NQR"
+msgstr ""
+
+#: client/admin/lib/constants.js:95
+msgid "Credit"
+msgstr ""
+
+#: client/admin/lib/constants.js:96
+msgid "Barter"
+msgstr ""
+
+#: client/admin/lib/constants.js:100
+msgid "Your unique payment modal title."
+msgstr ""
+
+#: client/admin/lib/constants.js:101
+msgid "This is your test API key. Retrieve this from your test mode dashboard."
+msgstr ""
+
+#: client/admin/lib/constants.js:105
+msgid "This is your test private key. Retrieve this from your test mode dashboard."
+msgstr ""
+
+#: client/admin/lib/constants.js:109
+msgid "This is your production public key for live transactions."
+msgstr ""
+
+#: client/admin/lib/constants.js:113
+msgid "This is your production private key for live transactions."
+msgstr ""
+
+#: client/admin/lib/validate.js:20
+msgid "Enter a title shoppers will see at checkout."
+msgstr ""
+
+#: client/admin/lib/validate.js:24
+msgid "Choose how the payment interface opens."
+msgstr ""
+
+#: client/admin/lib/validate.js:41
+msgid "Enter your test public key."
+msgstr ""
+
+#: client/admin/lib/validate.js:45
+msgid "Enter your test private key."
+msgstr ""
+
+#: client/admin/lib/validate.js:50
+msgid "Live transactions are enabled, so a live public key is required."
+msgstr ""
+
+#: client/admin/lib/validate.js:57
+msgid "Live transactions are enabled, so a live private key is required."
+msgstr ""
+
+#: client/admin/lib/validate.js:78
+msgid "Select at least one payment method."
+msgstr ""
+
+#: client/admin/screens/forms.js:37
+msgid "Title"
+msgstr ""
+
+#: client/admin/screens/forms.js:44
+msgid "Enter your title"
+msgstr ""
+
+#: client/admin/screens/forms.js:49
+msgid "Description"
+msgstr ""
+
+#: client/admin/screens/forms.js:53
+msgid "Enter your description"
+msgstr ""
+
+#: client/admin/screens/forms.js:60
+msgid "Checkout"
+msgstr ""
+
+#: client/admin/screens/forms.js:67
+msgid "Select option"
+msgstr ""
+
+#: client/admin/screens/forms.js:76
+msgid "Auto complete order"
+msgstr ""
+
+#: client/admin/screens/forms.js:94
+msgid "Test public key"
+msgstr ""
+
+#: client/admin/screens/forms.js:95
+msgid "Enter your test public key"
+msgstr ""
+
+#: client/admin/screens/forms.js:99
+msgid "Test private key"
+msgstr ""
+
+#: client/admin/screens/forms.js:100
+msgid "Enter your test private key"
+msgstr ""
+
+#: client/admin/screens/forms.js:104
+msgid "Live public key"
+msgstr ""
+
+#: client/admin/screens/forms.js:105
+msgid "Enter your live public key"
+msgstr ""
+
+#: client/admin/screens/forms.js:109
+msgid "Live private key"
+msgstr ""
+
+#: client/admin/screens/forms.js:110
+msgid "Enter your live private key"
+msgstr ""
+
+#: client/admin/screens/forms.js:137
+msgid "Webhook"
+msgstr ""
+
+#: client/admin/screens/forms.js:138
+msgid "Add this webhook URL to your webhook settings in your F4B dashboard."
+msgstr ""
+
+#: client/admin/screens/forms.js:150
+msgid "Enable live transactions"
+msgstr ""
+
+#: client/admin/screens/forms.js:223
+msgid "Advanced"
+msgstr ""
+
+#: client/admin/screens/forms.js:230
+msgid "Additional Flutterwave payment options and diagnostics. These are not shown at checkout unless selected."
+msgstr ""
+
+#: client/admin/screens/forms.js:250
+msgid "Secret hash"
+msgstr ""
+
+#: client/admin/screens/forms.js:251
+msgid "Must match the secret hash set on your Flutterwave dashboard for webhooks to be accepted."
+msgstr ""
+
+#: client/admin/screens/forms.js:269
+msgid "Disable logging"
+msgstr ""
+
+#: client/admin/screens/Settings.js:18
+#: client/admin/screens/Settings.js:28
+#: client/admin/screens/Wizard.js:29
+msgid "General"
+msgstr ""
+
+#: client/admin/screens/Settings.js:23
+#: client/admin/screens/Settings.js:32
+#: client/admin/screens/Wizard.js:127
+msgid "Save & continue"
+msgstr ""
+
+#: client/admin/screens/Settings.js:27
+#: client/admin/screens/Settings.js:37
+#: client/admin/screens/Wizard.js:37
+msgid "API & Webhook"
+msgstr ""
+
+#: client/admin/screens/Settings.js:36
+msgid "Payment Methods"
+msgstr ""
+
+#: client/admin/screens/Settings.js:41
+#: client/admin/screens/Wizard.js:126
+msgid "Save"
+msgstr ""
+
+#: client/admin/screens/Settings.js:76
+msgid "Changes saved."
+msgstr ""
+
+#: client/admin/screens/Settings.js:84
+#: client/admin/screens/Wizard.js:94
+msgid "Could not save your settings. Please try again."
+msgstr ""
+
+#: client/admin/screens/Success.js:24
+msgid "Store setup successful!"
+msgstr ""
+
+#: client/admin/screens/Success.js:26
+msgid "You have successfully connected your Flutterwave for Business account to WooCommerce."
+msgstr ""
+
+#: client/admin/screens/Success.js:32
+msgid "Go to settings"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:25
+msgid "Welcome to Flutterwave"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:27
+msgid "for Business"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:31
+msgid "Flutterwave enables you to collect payments in over 30 currencies worldwide. With support for Cards, Mobile money, Bank transfer, Apple Pay, Google Pay and more."
+msgstr ""
+
+#: client/admin/screens/Welcome.js:38
+msgid "Activate Flutterwave"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:46
+msgid "Documentation"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:48
+msgid "Explore guides and resources to help you set up and manage your Flutterwave integration."
+msgstr ""
+
+#: client/admin/screens/Welcome.js:54
+msgid "Read Documentation"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:62
+msgid "Support"
+msgstr ""
+
+#: client/admin/screens/Welcome.js:64
+msgid "Contact our team for dedicated support with your integration."
+msgstr ""
+
+#: client/admin/screens/Welcome.js:70
+msgid "Contact Support"
+msgstr ""
+
+#: client/admin/screens/Wizard.js:20
+#: client/admin/screens/Wizard.js:28
+msgid "Please fill in your correct details to setup your store"
+msgstr ""
+
+#: client/admin/screens/Wizard.js:36
+msgid "Add your preferred methods to your checkout."
+msgstr ""
+
+#: client/admin/screens/Wizard.js:121
+msgid "Go back"
+msgstr ""
+
#: client/blocks/payment-method/index.js:19
-#: build/index.js:102
msgid "You may be redirected to a secure page to complete your payment."
msgstr ""
-#: build/index.js:1
#: client/blocks/payment-method/index.js:32
-#: build/index.js:115
msgid "Flutterwave"
msgstr ""
-#: build/index.js:1
#: client/blocks/payment-method/index.js:38
-#: build/index.js:121
msgid "Proceed to Flutterwave"
msgstr ""
-#: build/index.js:1
#: client/blocks/payment-method/index.js:44
-#: build/index.js:127
msgid "Payment via Flutterwave"
msgstr ""
-
-#: client/blocks/payment-request/custom-button.js:8
-msgid "Buy now"
-msgstr ""
-
-#: client/blocks/payment-request/payment-request-express.js:16
-msgid "Pay with Flutterwave!"
-msgstr ""
diff --git a/includes/admin/class-flutterwave-admin-page.php b/includes/admin/class-flutterwave-admin-page.php
new file mode 100644
index 0000000..9f983c5
--- /dev/null
+++ b/includes/admin/class-flutterwave-admin-page.php
@@ -0,0 +1,221 @@
+%1$s %2$s
%4$s
',
+ esc_html__( 'Flutterwave: action required.', 'rave-woocommerce-payment-gateway' ),
+ esc_html__( 'Webhooks are paused until you set a new secret hash. Generate one, save, then add it to the webhook settings on your Flutterwave dashboard.', 'rave-woocommerce-payment-gateway' ),
+ esc_url( add_query_arg( 'tab', 'api', self::get_url() ) ),
+ esc_html__( 'Generate a new secret hash', 'rave-woocommerce-payment-gateway' )
+ );
+ }
+
+ /**
+ * The URL of the standalone admin page.
+ *
+ * @return string
+ */
+ public static function get_url(): string {
+ return admin_url( 'admin.php?page=' . self::PAGE_SLUG );
+ }
+
+ /**
+ * Add the submenu entry under WooCommerce.
+ *
+ * @return void
+ */
+ public function register_menu(): void {
+ add_submenu_page(
+ 'woocommerce',
+ __( 'Flutterwave Payment', 'rave-woocommerce-payment-gateway' ),
+ __( 'Flutterwave Payment', 'rave-woocommerce-payment-gateway' ),
+ 'manage_woocommerce',
+ self::PAGE_SLUG,
+ array( $this, 'render' )
+ );
+ }
+
+ /**
+ * Send anyone landing on the legacy WooCommerce gateway section to the new page.
+ *
+ * Only redirects the plain view; POSTs are left alone so WooCommerce's own
+ * save handler is never interrupted.
+ *
+ * @return void
+ */
+ public function redirect_legacy_section(): void {
+ if ( ! is_admin() || wp_doing_ajax() ) {
+ return;
+ }
+
+ if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
+ return;
+ }
+
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing on a GET screen.
+ $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
+ $tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : '';
+ $section = isset( $_GET['section'] ) ? sanitize_text_field( wp_unslash( $_GET['section'] ) ) : '';
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ if ( 'wc-settings' !== $page || 'checkout' !== $tab || 'rave' !== $section ) {
+ return;
+ }
+
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return;
+ }
+
+ wp_safe_redirect( self::get_url() );
+ exit;
+ }
+
+ /**
+ * Load the React bundle and styles on our page only.
+ *
+ * @param string $hook_suffix Current admin page hook.
+ *
+ * @return void
+ */
+ public function enqueue_assets( string $hook_suffix ): void {
+ if ( 'woocommerce_page_' . self::PAGE_SLUG !== $hook_suffix ) {
+ return;
+ }
+
+ $asset_path = FLW_WC_DIR_PATH . 'build/admin.asset.php';
+
+ if ( ! file_exists( $asset_path ) ) {
+ return;
+ }
+
+ $asset = require $asset_path;
+
+ wp_enqueue_script(
+ 'flutterwave-admin',
+ FLW_WC_PLUGIN_URL . 'build/admin.js',
+ $asset['dependencies'],
+ $asset['version'],
+ true
+ );
+
+ if ( file_exists( FLW_WC_DIR_PATH . 'build/admin.css' ) ) {
+ wp_enqueue_style(
+ 'flutterwave-admin',
+ FLW_WC_PLUGIN_URL . 'build/admin.css',
+ array(),
+ $asset['version']
+ );
+
+ // wp-scripts emits admin-rtl.css alongside admin.css.
+ wp_style_add_data( 'flutterwave-admin', 'rtl', 'replace' );
+ }
+
+ wp_set_script_translations( 'flutterwave-admin', 'rave-woocommerce-payment-gateway', FLW_WC_DIR_PATH . 'i18n/languages' );
+
+ wp_localize_script(
+ 'flutterwave-admin',
+ 'flutterwaveAdminData',
+ array(
+ 'restUrl' => esc_url_raw( rest_url( Flutterwave_Settings::REST_NAMESPACE ) ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ 'assetsUrl' => FLW_WC_PLUGIN_URL . 'assets/img/admin/',
+ 'documentation' => 'https://developer.flutterwave.com/docs/woocommerce',
+ 'support' => 'https://support.flutterwave.com/',
+ 'dashboardUrl' => 'https://app.flutterwave.com/dashboard/settings/webhooks',
+ 'settingsUrl' => self::get_url(),
+ )
+ );
+ }
+
+ /**
+ * Print the mount point.
+ *
+ * @return void
+ */
+ public function render(): void {
+ echo '
';
+ }
+}
diff --git a/includes/admin/class-flutterwave-settings-controller.php b/includes/admin/class-flutterwave-settings-controller.php
new file mode 100644
index 0000000..f39199b
--- /dev/null
+++ b/includes/admin/class-flutterwave-settings-controller.php
@@ -0,0 +1,131 @@
+ \WP_REST_Server::READABLE,
+ 'callback' => array( __CLASS__, 'get_settings' ),
+ 'permission_callback' => array( __CLASS__, 'can_manage' ),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::EDITABLE,
+ 'callback' => array( __CLASS__, 'update_settings' ),
+ 'permission_callback' => array( __CLASS__, 'can_manage' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ Flutterwave_Settings::REST_NAMESPACE,
+ '/onboarding/complete',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( __CLASS__, 'complete_onboarding' ),
+ 'permission_callback' => array( __CLASS__, 'can_manage' ),
+ )
+ );
+ }
+
+ /**
+ * Only shop managers and administrators may read or write gateway keys.
+ *
+ * @return bool
+ */
+ public static function can_manage(): bool {
+ return current_user_can( 'manage_woocommerce' );
+ }
+
+ /**
+ * GET /settings.
+ *
+ * @return \WP_REST_Response
+ */
+ public static function get_settings(): \WP_REST_Response {
+ return rest_ensure_response( Flutterwave_Settings::to_payload() );
+ }
+
+ /**
+ * POST /settings.
+ *
+ * @param \WP_REST_Request $request Incoming request.
+ *
+ * @return \WP_REST_Response
+ */
+ public static function update_settings( \WP_REST_Request $request ): \WP_REST_Response {
+ $body = $request->get_json_params();
+
+ if ( ! is_array( $body ) ) {
+ $body = $request->get_params();
+ }
+
+ return rest_ensure_response( Flutterwave_Settings::update( (array) $body ) );
+ }
+
+ /**
+ * POST /onboarding/complete — flips the wizard flag and enables the gateway.
+ *
+ * @param \WP_REST_Request $request Incoming request.
+ *
+ * @return \WP_REST_Response
+ */
+ public static function complete_onboarding( \WP_REST_Request $request ): \WP_REST_Response {
+ $body = $request->get_json_params();
+
+ if ( ! is_array( $body ) ) {
+ $body = array();
+ }
+
+ $body['onboardingComplete'] = true;
+ $body['enabled'] = true;
+
+ $payload = Flutterwave_Settings::update( $body );
+
+ /**
+ * Fires once a merchant finishes the onboarding wizard.
+ *
+ * Lets the SigNoz app-registration path run for merchants who never
+ * touch the legacy WooCommerce settings screen.
+ *
+ * @since 3.4.0
+ */
+ do_action( 'woocommerce_update_options_payment_gateways_rave' );
+
+ return rest_ensure_response( $payload );
+ }
+}
diff --git a/includes/admin/class-flutterwave-settings.php b/includes/admin/class-flutterwave-settings.php
new file mode 100644
index 0000000..e43b017
--- /dev/null
+++ b/includes/admin/class-flutterwave-settings.php
@@ -0,0 +1,379 @@
+>
+ */
+ private const METHOD_MAP = array(
+ 'card' => array( 'card' ),
+ 'stablecoin' => array( 'stablecoin' ),
+ 'banktransfer' => array( 'banktransfer', 'account' ),
+ 'mobilemoney' => array(
+ 'mobilemoneyghana',
+ 'mobilemoneyfranco',
+ 'mobilemoneyrwanda',
+ 'mobilemoneyzambia',
+ 'mobilemoneyuganda',
+ 'mobilemoneytanzania',
+ 'mpesa',
+ ),
+ 'applepay' => array( 'applepay' ),
+ 'googlepay' => array( 'googlepay' ),
+ 'opay' => array( 'opay' ),
+ );
+
+ /**
+ * Payment option tokens the new UI does not surface as checkboxes but that
+ * merchants may still have selected. Kept editable under "Advanced".
+ *
+ * @var array
+ */
+ private const ADVANCED_METHODS = array( 'ussd', 'qr', 'nqr', 'credit', 'barter' );
+
+ /**
+ * Defaults applied when a key has never been saved.
+ *
+ * @return array
+ */
+ public static function defaults(): array {
+ return array(
+ 'enabled' => 'no',
+ 'title' => 'Flutterwave',
+ 'description' => 'Powered by Flutterwave: Accepts Mastercard, Visa, Verve, Discover, AMEX, Diners Club and Union Pay.',
+ 'test_public_key' => '',
+ 'test_secret_key' => '',
+ 'live_public_key' => '',
+ 'live_secret_key' => '',
+ 'payment_style' => 'inline',
+ 'autocomplete_order' => 'no',
+ 'payment_options' => 'card,ussd,account,mpesa,banktransfer,mobilemoneyghana,mobilemoneyfranco,mobilemoneyrwanda,mobilemoneyzambia,mobilemoneyuganda',
+ 'go_live' => 'no',
+ 'logging_option' => 'no',
+ 'barter' => 'no',
+ 'secret_hash' => '',
+ 'onboarding_complete' => 'no',
+ );
+ }
+
+ /**
+ * The raw stored option merged over the defaults.
+ *
+ * @return array
+ */
+ public static function raw(): array {
+ $stored = get_option( self::OPTION_KEY, array() );
+
+ if ( ! is_array( $stored ) ) {
+ $stored = array();
+ }
+
+ return array_merge( self::defaults(), $stored );
+ }
+
+ /**
+ * The payload the admin app consumes.
+ *
+ * @return array
+ */
+ public static function to_payload(): array {
+ self::ensure_secret_hash();
+
+ $settings = self::raw();
+ $onboarded = self::is_onboarded();
+
+ // A store that has never been set up starts the wizard with empty
+ // fields, so step one reads as something to fill in rather than
+ // something already answered. The storage defaults still stand behind
+ // them, so nothing downstream sees a blank.
+ $title = $onboarded ? (string) $settings['title'] : '';
+ $description = $onboarded ? (string) $settings['description'] : '';
+ $style = 'redirect' === $settings['payment_style'] ? 'redirect' : 'inline';
+
+ return array(
+ 'title' => $title,
+ 'description' => $description,
+ 'paymentStyle' => $onboarded ? $style : '',
+ 'autocompleteOrder' => 'yes' === $settings['autocomplete_order'],
+ 'testPublicKey' => (string) $settings['test_public_key'],
+ 'testSecretKey' => (string) $settings['test_secret_key'],
+ 'livePublicKey' => (string) $settings['live_public_key'],
+ 'liveSecretKey' => (string) $settings['live_secret_key'],
+ 'goLive' => 'yes' === $settings['go_live'],
+ 'secretHash' => (string) $settings['secret_hash'],
+ 'secretHashIsLegacy' => self::LEGACY_SECRET_HASH === $settings['secret_hash'],
+ 'disableLogging' => 'yes' === $settings['logging_option'],
+ 'disableBarter' => 'yes' === $settings['barter'],
+ 'enabled' => 'yes' === $settings['enabled'],
+ 'paymentMethods' => self::methods_from_option( (string) $settings['payment_options'] ),
+ 'advancedMethods' => self::advanced_from_option( (string) $settings['payment_options'] ),
+ 'onboardingComplete' => self::is_onboarded(),
+ 'webhookUrl' => self::webhook_url(),
+ );
+ }
+
+ /**
+ * The webhook URL merchants paste into their Flutterwave dashboard.
+ *
+ * @return string
+ */
+ public static function webhook_url(): string {
+ if ( function_exists( 'WC' ) && WC() instanceof \WooCommerce ) {
+ return WC()->api_request_url( 'Flw_WC_Payment_Webhook' );
+ }
+
+ return home_url( '/wc-api/Flw_WC_Payment_Webhook' );
+ }
+
+ /**
+ * Which UI payment-method checkboxes are on, given a stored option string.
+ *
+ * @param string $option_string Comma separated Flutterwave payment options.
+ *
+ * @return array
+ */
+ public static function methods_from_option( string $option_string ): array {
+ $tokens = self::tokenize( $option_string );
+
+ if ( empty( $tokens ) ) {
+ return array();
+ }
+
+ $enabled = array();
+
+ foreach ( self::METHOD_MAP as $key => $group ) {
+ if ( ! empty( array_intersect( $group, $tokens ) ) ) {
+ $enabled[] = $key;
+ }
+ }
+
+ return $enabled;
+ }
+
+ /**
+ * Tokens the merchant has enabled that the checkbox list does not cover.
+ *
+ * @param string $option_string Comma separated Flutterwave payment options.
+ *
+ * @return array
+ */
+ public static function advanced_from_option( string $option_string ): array {
+ $tokens = self::tokenize( $option_string );
+
+ return array_values( array_intersect( self::ADVANCED_METHODS, $tokens ) );
+ }
+
+ /**
+ * Build the option string Flutterwave expects from the UI selection.
+ *
+ * @param array $methods Checkbox keys.
+ * @param array $advanced Extra tokens to preserve.
+ *
+ * @return string
+ */
+ public static function option_from_methods( array $methods, array $advanced = array() ): string {
+ $tokens = array();
+
+ foreach ( $methods as $method ) {
+ if ( isset( self::METHOD_MAP[ $method ] ) ) {
+ $tokens = array_merge( $tokens, self::METHOD_MAP[ $method ] );
+ }
+ }
+
+ $tokens = array_merge( $tokens, array_intersect( self::ADVANCED_METHODS, $advanced ) );
+
+ return implode( ',', array_values( array_unique( $tokens ) ) );
+ }
+
+ /**
+ * Split and normalise a comma separated option string.
+ *
+ * @param string $option_string Raw option value.
+ *
+ * @return array
+ */
+ private static function tokenize( string $option_string ): array {
+ $parts = array_map( 'trim', explode( ',', $option_string ) );
+
+ return array_values( array_filter( $parts, static fn( $part ) => '' !== $part ) );
+ }
+
+ /**
+ * Persist a partial update coming from the admin app.
+ *
+ * Only keys present in the request are touched, so each wizard step can
+ * save independently without clobbering the others.
+ *
+ * @param array $patch Request body.
+ *
+ * @return array The refreshed payload.
+ */
+ public static function update( array $patch ): array {
+ $settings = self::raw();
+
+ $strings = array(
+ 'title' => 'title',
+ 'description' => 'description',
+ 'testPublicKey' => 'test_public_key',
+ 'testSecretKey' => 'test_secret_key',
+ 'livePublicKey' => 'live_public_key',
+ 'liveSecretKey' => 'live_secret_key',
+ 'secretHash' => 'secret_hash',
+ );
+
+ foreach ( $strings as $incoming => $stored_key ) {
+ if ( array_key_exists( $incoming, $patch ) ) {
+ $settings[ $stored_key ] = sanitize_text_field( (string) $patch[ $incoming ] );
+ }
+ }
+
+ $booleans = array(
+ 'autocompleteOrder' => 'autocomplete_order',
+ 'goLive' => 'go_live',
+ 'disableLogging' => 'logging_option',
+ 'disableBarter' => 'barter',
+ 'enabled' => 'enabled',
+ );
+
+ foreach ( $booleans as $incoming => $stored_key ) {
+ if ( array_key_exists( $incoming, $patch ) ) {
+ $settings[ $stored_key ] = filter_var( $patch[ $incoming ], FILTER_VALIDATE_BOOLEAN ) ? 'yes' : 'no';
+ }
+ }
+
+ if ( array_key_exists( 'paymentStyle', $patch ) ) {
+ $settings['payment_style'] = 'redirect' === $patch['paymentStyle'] ? 'redirect' : 'inline';
+ }
+
+ if ( array_key_exists( 'paymentMethods', $patch ) ) {
+ $methods = is_array( $patch['paymentMethods'] ) ? array_map( 'sanitize_key', $patch['paymentMethods'] ) : array();
+ $advanced = array_key_exists( 'advancedMethods', $patch ) && is_array( $patch['advancedMethods'] )
+ ? array_map( 'sanitize_key', $patch['advancedMethods'] )
+ : self::advanced_from_option( (string) $settings['payment_options'] );
+
+ $settings['payment_options'] = self::option_from_methods( $methods, $advanced );
+ }
+
+ if ( array_key_exists( 'onboardingComplete', $patch ) ) {
+ $settings['onboarding_complete'] = filter_var( $patch['onboardingComplete'], FILTER_VALIDATE_BOOLEAN ) ? 'yes' : 'no';
+ }
+
+ // Never store a blank hash, and never let the old default be saved back
+ // in. One that is merely already stored is left for the merchant to
+ // replace (the admin notice and the webhook handler insist on it), so
+ // the new value is one they have seen and can copy to their dashboard.
+ $hash = trim( (string) $settings['secret_hash'] );
+
+ if ( '' === $hash || ( array_key_exists( 'secretHash', $patch ) && self::LEGACY_SECRET_HASH === $hash ) ) {
+ $settings['secret_hash'] = self::generate_secret_hash();
+ }
+
+ update_option( self::OPTION_KEY, $settings );
+
+ return self::to_payload();
+ }
+
+ /**
+ * Whether a stored secret hash may be used to authenticate webhooks.
+ * Blank and the old shared default both fail.
+ *
+ * @param string $hash Stored secret hash.
+ *
+ * @return bool
+ */
+ public static function is_usable_secret_hash( string $hash ): bool {
+ $hash = trim( $hash );
+
+ return '' !== $hash && ! hash_equals( self::LEGACY_SECRET_HASH, $hash );
+ }
+
+ /**
+ * A random 64-character hex secret hash.
+ *
+ * @return string
+ */
+ public static function generate_secret_hash(): string {
+ return hash( 'sha256', wp_generate_password( 64, true, true ) );
+ }
+
+ /**
+ * Store a generated secret hash when none is set, so a new install shows
+ * a real value on the API & Webhook step before anything has been saved.
+ * Nothing points at a blank hash yet, so filling it in breaks nothing.
+ *
+ * @return void
+ */
+ public static function ensure_secret_hash(): void {
+ $settings = self::raw();
+
+ if ( '' !== trim( (string) $settings['secret_hash'] ) ) {
+ return;
+ }
+
+ $settings['secret_hash'] = self::generate_secret_hash();
+
+ update_option( self::OPTION_KEY, $settings );
+ }
+
+ /**
+ * Whether the merchant has finished (or previously configured) setup.
+ *
+ * Merchants upgrading from an older version never ran the wizard but do
+ * have keys saved, so they are treated as already onboarded.
+ *
+ * @return bool
+ */
+ public static function is_onboarded(): bool {
+ $settings = self::raw();
+
+ if ( 'yes' === $settings['onboarding_complete'] ) {
+ return true;
+ }
+
+ return '' !== trim( (string) $settings['test_public_key'] ) || '' !== trim( (string) $settings['live_public_key'] );
+ }
+}
diff --git a/includes/class-flw-wc-payment-gateway.php b/includes/class-flw-wc-payment-gateway.php
index 861ecbe..e72a683 100644
--- a/includes/class-flw-wc-payment-gateway.php
+++ b/includes/class-flw-wc-payment-gateway.php
@@ -29,6 +29,7 @@
require_once __DIR__ . '/util/class-flutterwave-signoz-logger.php';
require_once __DIR__ . '/util/class-flutterwave-callback.php';
require_once __DIR__ . '/util/class-flutterwave-crypto.php';
+require_once __DIR__ . '/admin/class-flutterwave-settings.php';
use Flutterwave\WooCommerce\Client\Flw_WC_Payment_Gateway_Request;
use Flutterwave\WooCommerce\Client\FLW_WC_Payment_Gateway_Sdk as FlwSdk;
@@ -37,6 +38,7 @@
use Flutterwave\WooCommerce\Util\Flutterwave_Signoz_Logger;
use Flutterwave\WooCommerce\Util\Flutterwave_Callback;
use Flutterwave\WooCommerce\Util\Flutterwave_Crypto;
+use Flutterwave\WooCommerce\Admin\Flutterwave_Settings;
/**
* Main Flutterwave Gateway Class
@@ -320,8 +322,10 @@ public function init_form_fields() {
'secret_hash' => array(
'title' => __( 'Enter Secret Hash', 'rave-woocommerce-payment-gateway' ),
'type' => 'text',
- 'description' => __( 'Please change from default hash and ensure that SECRET HASH is the same with the one on your Flutterwave dashboard', 'rave-woocommerce-payment-gateway' ),
- 'default' => hash( 'sha256', 'Rave-Secret-Hash' ),
+ 'description' => __( 'Ensure that SECRET HASH is the same with the one on your Flutterwave dashboard', 'rave-woocommerce-payment-gateway' ),
+ // No default: each store gets its own generated hash from the
+ // settings screen.
+ 'default' => '',
),
'title' => array(
'title' => __( 'Payment method title', 'rave-woocommerce-payment-gateway' ),
@@ -739,11 +743,20 @@ public function flutterwave_webhooks() {
$local_signature = (string) $this->get_option( 'secret_hash' );
- if ( '' === $local_signature ) {
- $this->logger->error( 'A webhook arrived but no secret hash is configured, so it cannot be authenticated. Set one in the Flutterwave settings.' );
+ // A blank hash and the old default are both refused, with the same
+ // response so a caller cannot tell the two apart.
+ if ( ! Flutterwave_Settings::is_usable_secret_hash( $local_signature ) ) {
+ $missing = '' === trim( $local_signature );
+ $this->logger->error(
+ $missing
+ ? 'A webhook arrived but no secret hash is configured, so it cannot be authenticated. Set one in the Flutterwave settings.'
+ : 'A webhook was refused because the store still has the old default secret hash. Generate a new one in the Flutterwave settings.'
+ );
$this->signoz_logger->track_error(
- 'WEBHOOK_SECRET_HASH_MISSING',
- 'Webhook rejected because the store has no secret hash configured.'
+ $missing ? 'WEBHOOK_SECRET_HASH_MISSING' : 'WEBHOOK_SECRET_HASH_DEFAULT',
+ $missing
+ ? 'Webhook rejected because the store has no secret hash configured.'
+ : 'Webhook rejected because the store still uses the old default secret hash.'
);
wp_send_json(
array(
diff --git a/package.json b/package.json
index b843e4e..5f86757 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"postinstall": "composer install",
"prebuild": "npm install && composer install",
"build": "npm run uglify && composer run makepot && npm run build:webpack && npm run plugin-zip",
- "build:webpack": "wp-scripts build",
+ "build:webpack": "rimraf build/* && wp-scripts build",
"i18n:merge": "php bin/update-pot-file-references.php i18n/languages/rave-woocommerce-payment-gateway.pot",
"start": "npm run start:webpack",
"start:webpack": "rimraf build/* && wp-scripts start",
diff --git a/phpcs.xml b/phpcs.xml
index 381ab7e..70f6151 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -35,6 +35,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rave-woocommerce-payment-gateway.php b/rave-woocommerce-payment-gateway.php
index 2aba993..e95ea33 100644
--- a/rave-woocommerce-payment-gateway.php
+++ b/rave-woocommerce-payment-gateway.php
@@ -78,7 +78,7 @@ function () {
*/
function flw_plugin_action_links( array $links ): array {
- $rave_settings_url = esc_url( get_admin_url( null, 'admin.php?page=wc-settings&tab=checkout§ion=rave' ) );
+ $rave_settings_url = esc_url( get_admin_url( null, 'admin.php?page=flutterwave-payment' ) );
array_unshift( $links, "Settings " );
return $links;
@@ -91,8 +91,12 @@ function flw_plugin_action_links( array $links ): array {
function () {
require_once __DIR__ . '/includes/util/class-flutterwave-signoz-logger.php';
require_once __DIR__ . '/includes/util/class-flutterwave-app-registration.php';
+ require_once __DIR__ . '/includes/admin/class-flutterwave-admin-page.php';
+ require_once __DIR__ . '/includes/admin/class-flutterwave-settings-controller.php';
\Flutterwave\WooCommerce\Util\Flutterwave_Signoz_Logger::register_hooks();
\Flutterwave\WooCommerce\Util\Flutterwave_App_Registration::register_hooks();
+ \Flutterwave\WooCommerce\Admin\Flutterwave_Admin_Page::register_hooks();
+ \Flutterwave\WooCommerce\Admin\Flutterwave_Settings_Controller::register_hooks();
}
);
diff --git a/readme.txt b/readme.txt
index bad9e3b..e6956ca 100644
--- a/readme.txt
+++ b/readme.txt
@@ -2,7 +2,7 @@
Contributors: theflutterwave
Tags: fintech,flutterwave, woocommerce, payments, nigeria, mastercard, visa, target,Naira,payments,verve,donation,church,shop,store, ghana, kenya, international, mastercard, visa
Requires at least: 5.6
-Tested up to: 7.0.0
+Tested up to: 7.1.2
Stable tag: 3.3.1
License: MIT
License URI: https://github.com/Flutterwave/Woocommerce/blob/master/LICENSE
@@ -65,7 +65,7 @@ Handle Webhooks from Flutterwave with two new actions in WooCommerce.
= 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
+3. Ensure the secret hash on the API & Webhook tab matches the one on your Flutterwave Dashboard. The plugin generates a random one; use the Generate button to replace it at any time
4. Always ensure you install the most recent version of the Flutterwave Wordpress plugin
= Debugging Errors =
diff --git a/tests/PHPUnit/test-flutterwave-admin-settings.php b/tests/PHPUnit/test-flutterwave-admin-settings.php
new file mode 100644
index 0000000..fa9da00
--- /dev/null
+++ b/tests/PHPUnit/test-flutterwave-admin-settings.php
@@ -0,0 +1,318 @@
+assertSame( 'card', Flutterwave_Settings::option_from_methods( array( 'card' ) ) );
+ $this->assertSame(
+ 'card,banktransfer,account',
+ Flutterwave_Settings::option_from_methods( array( 'card', 'banktransfer' ) )
+ );
+ }
+
+ /**
+ * Mobile money expands to every country variant Flutterwave splits it into.
+ */
+ public function test_mobile_money_expands_to_country_variants() {
+ $option = Flutterwave_Settings::option_from_methods( array( 'mobilemoney' ) );
+
+ $this->assertStringContainsString( 'mobilemoneyghana', $option );
+ $this->assertStringContainsString( 'mobilemoneyrwanda', $option );
+ $this->assertStringContainsString( 'mpesa', $option );
+ }
+
+ /**
+ * Reading a stored option back lights up the right checkboxes.
+ */
+ public function test_payment_options_map_back_to_methods() {
+ $methods = Flutterwave_Settings::methods_from_option( 'card,mpesa,applepay' );
+
+ $this->assertContains( 'card', $methods );
+ $this->assertContains( 'mobilemoney', $methods );
+ $this->assertContains( 'applepay', $methods );
+ $this->assertNotContains( 'opay', $methods );
+ }
+
+ /**
+ * The legacy "All" default keeps working when read by the new UI.
+ */
+ public function test_legacy_default_option_string_round_trips() {
+ $legacy = 'card,ussd,account,mpesa,banktransfer,mobilemoneyghana,mobilemoneyfranco,mobilemoneyrwanda, mobilemoneyzambia,mobilemoneyuganda,ussd';
+ $methods = Flutterwave_Settings::methods_from_option( $legacy );
+
+ $this->assertContains( 'card', $methods );
+ $this->assertContains( 'banktransfer', $methods );
+ $this->assertContains( 'mobilemoney', $methods );
+
+ // USSD has no checkbox, so it must survive as an advanced token.
+ $this->assertContains( 'ussd', Flutterwave_Settings::advanced_from_option( $legacy ) );
+ }
+
+ /**
+ * Saving methods must not silently drop options the new UI does not show.
+ */
+ public function test_advanced_tokens_survive_a_save() {
+ update_option(
+ Flutterwave_Settings::OPTION_KEY,
+ array_merge(
+ Flutterwave_Settings::defaults(),
+ array( 'payment_options' => 'card,ussd,qr' )
+ )
+ );
+
+ Flutterwave_Settings::update( array( 'paymentMethods' => array( 'card', 'applepay' ) ) );
+
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertStringContainsString( 'ussd', $stored['payment_options'] );
+ $this->assertStringContainsString( 'qr', $stored['payment_options'] );
+ $this->assertStringContainsString( 'applepay', $stored['payment_options'] );
+ }
+
+ /**
+ * A partial update leaves untouched keys alone, so one wizard step cannot
+ * clobber another.
+ */
+ public function test_partial_update_preserves_other_keys() {
+ Flutterwave_Settings::update(
+ array(
+ 'title' => 'Minna Store',
+ 'testPublicKey' => 'FLWPUBK_TEST-abc',
+ )
+ );
+
+ Flutterwave_Settings::update( array( 'description' => 'Buy all your ultimate online wears.' ) );
+
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertSame( 'Minna Store', $stored['title'] );
+ $this->assertSame( 'FLWPUBK_TEST-abc', $stored['test_public_key'] );
+ $this->assertSame( 'Buy all your ultimate online wears.', $stored['description'] );
+ }
+
+ /**
+ * Booleans from the UI become the yes/no strings WooCommerce stores.
+ */
+ public function test_booleans_are_stored_as_yes_no() {
+ Flutterwave_Settings::update(
+ array(
+ 'goLive' => true,
+ 'autocompleteOrder' => false,
+ )
+ );
+
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertSame( 'yes', $stored['go_live'] );
+ $this->assertSame( 'no', $stored['autocomplete_order'] );
+ }
+
+ /**
+ * Saving never leaves the secret hash blank, so webhook verification is
+ * never comparing against an empty string.
+ */
+ public function test_secret_hash_is_generated_when_missing() {
+ Flutterwave_Settings::update( array( 'title' => 'Minna Store' ) );
+
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertNotEmpty( $stored['secret_hash'] );
+ $this->assertSame( 64, strlen( $stored['secret_hash'] ) );
+ }
+
+ /**
+ * An explicitly set secret hash is never overwritten on later saves.
+ */
+ public function test_existing_secret_hash_is_preserved() {
+ Flutterwave_Settings::update( array( 'secretHash' => 'my-dashboard-hash' ) );
+ Flutterwave_Settings::update( array( 'title' => 'Minna Store' ) );
+
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertSame( 'my-dashboard-hash', $stored['secret_hash'] );
+ }
+
+ /**
+ * Loading the admin app on a fresh install stores a random hash, so the
+ * API & Webhook step shows a real value before anything is saved.
+ */
+ public function test_payload_generates_and_stores_secret_hash_on_fresh_install() {
+ $payload = Flutterwave_Settings::to_payload();
+ $stored = get_option( Flutterwave_Settings::OPTION_KEY );
+
+ $this->assertSame( 64, strlen( $payload['secretHash'] ) );
+ $this->assertSame( $stored['secret_hash'], $payload['secretHash'] );
+ $this->assertNotSame( Flutterwave_Settings::LEGACY_SECRET_HASH, $payload['secretHash'] );
+ $this->assertFalse( $payload['secretHashIsLegacy'] );
+ }
+
+ /**
+ * Two installs never end up sharing a generated hash.
+ */
+ public function test_generated_secret_hashes_are_unique() {
+ $this->assertNotSame( Flutterwave_Settings::generate_secret_hash(), Flutterwave_Settings::generate_secret_hash() );
+ }
+
+ /**
+ * The constant matches the value older versions shipped as the default.
+ */
+ public function test_legacy_secret_hash_constant_matches_old_default() {
+ $this->assertSame( hash( 'sha256', 'Rave-Secret-Hash' ), Flutterwave_Settings::LEGACY_SECRET_HASH );
+ }
+
+ /**
+ * A store still on the old default is flagged, but the stored hash is not
+ * swapped out on an unrelated save: the merchant has to generate the new
+ * one on the API & Webhook tab, where they can copy it to their dashboard.
+ */
+ public function test_legacy_secret_hash_is_flagged_but_kept() {
+ update_option( Flutterwave_Settings::OPTION_KEY, array( 'secret_hash' => Flutterwave_Settings::LEGACY_SECRET_HASH ) );
+
+ $payload = Flutterwave_Settings::update( array( 'title' => 'Minna Store' ) );
+
+ $this->assertTrue( $payload['secretHashIsLegacy'] );
+ $this->assertSame( Flutterwave_Settings::LEGACY_SECRET_HASH, get_option( Flutterwave_Settings::OPTION_KEY )['secret_hash'] );
+ }
+
+ /**
+ * Webhooks are only authenticated against a hash the merchant has set:
+ * blank and the old default are both refused.
+ */
+ public function test_only_generated_or_custom_hashes_are_usable_for_webhooks() {
+ $this->assertFalse( Flutterwave_Settings::is_usable_secret_hash( '' ) );
+ $this->assertFalse( Flutterwave_Settings::is_usable_secret_hash( ' ' ) );
+ $this->assertFalse( Flutterwave_Settings::is_usable_secret_hash( Flutterwave_Settings::LEGACY_SECRET_HASH ) );
+ $this->assertTrue( Flutterwave_Settings::is_usable_secret_hash( Flutterwave_Settings::generate_secret_hash() ) );
+ $this->assertTrue( Flutterwave_Settings::is_usable_secret_hash( 'my-dashboard-hash' ) );
+ }
+
+ /**
+ * Explicitly saving the old default replaces it with a generated hash.
+ */
+ public function test_saving_legacy_secret_hash_generates_a_new_one() {
+ $payload = Flutterwave_Settings::update( array( 'secretHash' => Flutterwave_Settings::LEGACY_SECRET_HASH ) );
+
+ $this->assertNotSame( Flutterwave_Settings::LEGACY_SECRET_HASH, $payload['secretHash'] );
+ $this->assertSame( 64, strlen( $payload['secretHash'] ) );
+ $this->assertFalse( $payload['secretHashIsLegacy'] );
+ }
+
+ /**
+ * The legacy gateway form no longer falls back to a shared, public hash.
+ */
+ public function test_gateway_form_has_no_default_secret_hash() {
+ $gateway = new \FLW_WC_Payment_Gateway();
+
+ $this->assertSame( '', $gateway->form_fields['secret_hash']['default'] );
+ }
+
+ /**
+ * Admins on the old default see a warning linking to the API & Webhook tab.
+ */
+ public function test_legacy_secret_hash_shows_admin_notice() {
+ update_option( Flutterwave_Settings::OPTION_KEY, array( 'secret_hash' => Flutterwave_Settings::LEGACY_SECRET_HASH ) );
+
+ $output = $this->capture_notice();
+
+ $this->assertStringContainsString( 'flw-legacy-secret-hash-notice', $output );
+ $this->assertStringContainsString( 'tab=api', $output );
+ }
+
+ /**
+ * No notice once the store has its own hash.
+ */
+ public function test_custom_secret_hash_shows_no_admin_notice() {
+ update_option( Flutterwave_Settings::OPTION_KEY, array( 'secret_hash' => Flutterwave_Settings::generate_secret_hash() ) );
+
+ $this->assertSame( '', $this->capture_notice() );
+ }
+
+ /**
+ * Users who cannot fix the setting are not shown the notice.
+ */
+ public function test_legacy_secret_hash_notice_hidden_from_non_managers() {
+ update_option( Flutterwave_Settings::OPTION_KEY, array( 'secret_hash' => Flutterwave_Settings::LEGACY_SECRET_HASH ) );
+
+ $this->assertSame( '', $this->capture_notice( 'subscriber' ) );
+ }
+
+ /**
+ * Render the secret hash notice as a user with the given role.
+ *
+ * @param string $role User role.
+ *
+ * @return string The notice markup, or '' when none is shown.
+ */
+ private function capture_notice( string $role = 'administrator' ): string {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) );
+
+ ob_start();
+ \Flutterwave\WooCommerce\Admin\Flutterwave_Admin_Page::instance()->legacy_secret_hash_notice();
+
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * A fresh install sees the welcome screen.
+ */
+ public function test_fresh_install_is_not_onboarded() {
+ $this->assertFalse( Flutterwave_Settings::is_onboarded() );
+ }
+
+ /**
+ * Merchants upgrading with keys already saved skip the wizard, even though
+ * they never ran it.
+ */
+ public function test_existing_merchant_with_keys_is_treated_as_onboarded() {
+ update_option(
+ Flutterwave_Settings::OPTION_KEY,
+ array_merge(
+ Flutterwave_Settings::defaults(),
+ array( 'test_public_key' => 'FLWPUBK_TEST-abc' )
+ )
+ );
+
+ $this->assertTrue( Flutterwave_Settings::is_onboarded() );
+ $this->assertTrue( Flutterwave_Settings::to_payload()['onboardingComplete'] );
+ }
+
+ /**
+ * The payload exposes the webhook URL the API & webhook step asks merchants
+ * to copy.
+ */
+ public function test_payload_exposes_webhook_url() {
+ $payload = Flutterwave_Settings::to_payload();
+
+ $this->assertArrayHasKey( 'webhookUrl', $payload );
+ $this->assertStringContainsString( 'Flw_WC_Payment_Webhook', $payload['webhookUrl'] );
+ }
+}
diff --git a/tests/PHPUnit/test-flw-wc-payment-gateway.php b/tests/PHPUnit/test-flw-wc-payment-gateway.php
index dfc4de1..35464ae 100644
--- a/tests/PHPUnit/test-flw-wc-payment-gateway.php
+++ b/tests/PHPUnit/test-flw-wc-payment-gateway.php
@@ -140,7 +140,7 @@ public function test_webhook_is_accessible( string $hash, array $data, array $wb
* Tests the gateway webhook on invalid transaction reference.
*/
public function test_webhook_transaction_not_found() {
- $hash = "a4a6e4c86fc1347a48eeab1171f7fea1a10eecbac223b86db3b3e3e134fefa40";
+ $hash = "581e4231-441e-4730-88bf-8f181897759ea8f1";
$data = [
"event" => "charge.completed",
"data" => ["tx_ref" => "Rave-Pages846040622798"]
@@ -209,7 +209,7 @@ function retrieve_response_code() : int {
public function webhook_204_provider(): array {
return[
[
- 'a4a6e4c86fc1347a48eeab1171f7fea1a10eecbac223b86db3b3e3e134fefa40',
+ '581e4231-441e-4730-88bf-8f181897759ea8f1',
array(),
array(
'status' => 'error',
@@ -323,7 +323,7 @@ public function webhook_provider(): array {
return [
[
- 'a4a6e4c86fc1347a48eeab1171f7fea1a10eecbac223b86db3b3e3e134fefa40',
+ '581e4231-441e-4730-88bf-8f181897759ea8f1',
array(
'amount' => 2000,
'currency' => 'NGN',
@@ -336,7 +336,7 @@ public function webhook_provider(): array {
)
],
[
- 'a4a6e4c86fc1347a48eeab1171f7fea1a10eecbac223b86db3b3e3e134fefa40',
+ '581e4231-441e-4730-88bf-8f181897759ea8f1',
$wbk_request['failed'],
array(
'status' => 'success',
@@ -344,7 +344,7 @@ public function webhook_provider(): array {
)
],
[
- 'a4a6e4c86fc1347a48eeab1171f7fea1a10eecbac223b86db3b3e3e134fefa40',
+ '581e4231-441e-4730-88bf-8f181897759ea8f1',
$wbk_request['success'],
array(
'status' => 'success',
diff --git a/webpack.config.js b/webpack.config.js
index 21ac760..c681e6f 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -33,6 +33,7 @@ const CLIENT_DIR = path.resolve( __dirname, 'client' );
const entry = {
index: CLIENT_DIR + '/blocks/index.js',
+ admin: CLIENT_DIR + '/admin/index.js',
};
const rules = [