diff --git a/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx b/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx
index 90f47d004a..f2b26c487c 100644
--- a/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx
+++ b/packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx
@@ -1,9 +1,10 @@
import { createCheckoutService, createLanguageService } from '@bigcommerce/checkout-sdk';
import { createOfflinePaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/offline';
+import { Formik } from 'formik';
import React from 'react';
import { type PaymentMethodProps } from '@bigcommerce/checkout/payment-integration-api';
-import { render } from '@bigcommerce/checkout/test-utils';
+import { render, screen } from '@bigcommerce/checkout/test-utils';
import OfflinePaymentMethod from './OfflinePaymentMethod';
import { getMethod } from './payment-method.mock';
@@ -72,4 +73,48 @@ describe('OfflinePaymentMethod', () => {
await expect(checkoutService.deinitializePayment).rejects.toThrow('test error');
});
+
+ describe('formFieldsData', () => {
+ const formFieldsData = [
+ {
+ name: 'Purchase Order',
+ id: 'purchaseOrderNumber',
+ required: true,
+ type: 'number',
+ fieldType: 'text',
+ },
+ ];
+
+ it('returns null when formFieldsData is not present', () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('returns null when formFieldsData is an empty array', () => {
+ const props = {
+ ...defaultProps,
+ method: { ...defaultProps.method, initializationData: { formFieldsData: [] } },
+ };
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders fields from formFieldsData via PaymentFormFields', () => {
+ const props = {
+ ...defaultProps,
+ method: { ...defaultProps.method, initializationData: { formFieldsData } },
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx b/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx
index 30ebee589a..e2e6d3e28d 100644
--- a/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx
+++ b/packages/offline-payment-integration/src/OfflinePaymentMethod.tsx
@@ -1,11 +1,14 @@
import { createOfflinePaymentStrategy } from '@bigcommerce/checkout-sdk/integrations/offline';
-import { type FunctionComponent, useEffect } from 'react';
+import React, { type FunctionComponent, useEffect } from 'react';
import {
+ getPaymentFormFields,
type PaymentMethodProps,
toResolvableComponent,
} from '@bigcommerce/checkout/payment-integration-api';
+import PaymentFormFields from './PaymentFormFields';
+
const OfflinePaymentMethod: FunctionComponent = ({
method,
checkoutService,
@@ -46,7 +49,13 @@ const OfflinePaymentMethod: FunctionComponent = ({
};
}, [checkoutService, method.gateway, method.id, onUnhandledError]);
- return null;
+ const formFields = getPaymentFormFields(method.initializationData?.formFieldsData);
+
+ if (!formFields.length) {
+ return null;
+ }
+
+ return ;
};
export default toResolvableComponent(OfflinePaymentMethod, [
diff --git a/packages/offline-payment-integration/src/PaymentFormFields.test.tsx b/packages/offline-payment-integration/src/PaymentFormFields.test.tsx
new file mode 100644
index 0000000000..5b6b47efbd
--- /dev/null
+++ b/packages/offline-payment-integration/src/PaymentFormFields.test.tsx
@@ -0,0 +1,73 @@
+import { Formik } from 'formik';
+import React from 'react';
+
+import { type PaymentFormField } from '@bigcommerce/checkout/payment-integration-api';
+import { fireEvent, render, screen } from '@bigcommerce/checkout/test-utils';
+
+import PaymentFormFields from './PaymentFormFields';
+
+const fields: PaymentFormField[] = [
+ {
+ name: 'Purchase Order',
+ id: 'purchaseOrderNumber',
+ required: true,
+ type: 'number',
+ fieldType: 'text',
+ },
+ {
+ name: 'Reference',
+ id: 'referenceText',
+ required: false,
+ type: 'text',
+ fieldType: 'text',
+ },
+];
+
+const renderWithFormik = (ui: React.ReactElement) =>
+ render(
+
+ {ui}
+ ,
+ );
+
+describe('PaymentFormFields', () => {
+ it('renders a label and input for each field', () => {
+ renderWithFormik();
+
+ expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument();
+ expect(screen.getByLabelText('Reference')).toBeInTheDocument();
+ });
+
+ it('uses numeric inputMode for number type fields', () => {
+ renderWithFormik();
+
+ expect(screen.getByLabelText('Purchase Order')).toHaveAttribute('inputmode', 'numeric');
+ expect(screen.getByLabelText('Reference')).not.toHaveAttribute('inputmode');
+ });
+
+ it('strips non-digit characters from a number field on change', () => {
+ renderWithFormik();
+
+ const input = screen.getByLabelText('Purchase Order');
+
+ fireEvent.change(input, { target: { value: '123abc' } });
+
+ expect((input as HTMLInputElement).value).toBe('123');
+ });
+
+ it('shows a validation error for a required field left empty', async () => {
+ renderWithFormik();
+
+ fireEvent.blur(screen.getByLabelText('Purchase Order'));
+
+ expect(await screen.findByText('Purchase Order is required')).toBeInTheDocument();
+ });
+
+ it('does not show a validation error for an optional field left empty', () => {
+ renderWithFormik();
+
+ fireEvent.blur(screen.getByLabelText('Reference'));
+
+ expect(screen.queryByText('Reference is required')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/offline-payment-integration/src/PaymentFormFields.tsx b/packages/offline-payment-integration/src/PaymentFormFields.tsx
new file mode 100644
index 0000000000..6f23208c07
--- /dev/null
+++ b/packages/offline-payment-integration/src/PaymentFormFields.tsx
@@ -0,0 +1,55 @@
+import React, { type FunctionComponent } from 'react';
+
+import {
+ getPaymentFormFields,
+ type PaymentFormField,
+} from '@bigcommerce/checkout/payment-integration-api';
+import { BasicFormField, TextInput } from '@bigcommerce/checkout/ui';
+
+export { getPaymentFormFields, type PaymentFormField };
+
+const validateField = (value: string, field: PaymentFormField): string | undefined => {
+ if (field.required && !value) {
+ return `${field.name} is required`;
+ }
+
+ if (field.type === 'number' && value && !/^\d+$/.test(value)) {
+ return `${field.name} must be a positive whole number`;
+ }
+};
+
+const PaymentFormFields: FunctionComponent<{ fields: PaymentFormField[] }> = ({ fields }) => (
+ <>
+ {fields.map((field) => (
+ (
+
+
+
{
+ e.target.value = e.target.value.replace(/\D/g, '');
+ fieldProps.onChange(e);
+ }
+ : fieldProps.onChange
+ }
+ type="text"
+ />
+ {meta.touched && meta.error ? (
+ {meta.error}
+ ) : null}
+
+ )}
+ validate={(value: string) => validateField(value, field)}
+ />
+ ))}
+ >
+);
+
+export default PaymentFormFields;
diff --git a/packages/payment-integration-api/src/PaymentFormFields.test.tsx b/packages/payment-integration-api/src/PaymentFormFields.test.tsx
new file mode 100644
index 0000000000..0f6c32504a
--- /dev/null
+++ b/packages/payment-integration-api/src/PaymentFormFields.test.tsx
@@ -0,0 +1,29 @@
+import { getPaymentFormFields, type PaymentFormField } from './PaymentFormFields';
+
+const fields: PaymentFormField[] = [
+ {
+ name: 'Purchase Order',
+ id: 'purchaseOrderNumber',
+ required: true,
+ type: 'number',
+ fieldType: 'text',
+ },
+];
+
+describe('getPaymentFormFields', () => {
+ it('returns empty array when value is undefined', () => {
+ expect(getPaymentFormFields(undefined)).toEqual([]);
+ });
+
+ it('returns empty array when value is null', () => {
+ expect(getPaymentFormFields(null)).toEqual([]);
+ });
+
+ it('returns empty array when value is not an array', () => {
+ expect(getPaymentFormFields({ formFieldsData: [] })).toEqual([]);
+ });
+
+ it('returns the array when value is a valid array', () => {
+ expect(getPaymentFormFields(fields)).toEqual(fields);
+ });
+});
diff --git a/packages/payment-integration-api/src/PaymentFormFields.tsx b/packages/payment-integration-api/src/PaymentFormFields.tsx
new file mode 100644
index 0000000000..a54c5e259e
--- /dev/null
+++ b/packages/payment-integration-api/src/PaymentFormFields.tsx
@@ -0,0 +1,13 @@
+export interface PaymentFormField {
+ name: string;
+ id: string;
+ required: boolean;
+ type: string;
+ fieldType: string;
+}
+
+const isPaymentFormFieldArray = (value: unknown): value is PaymentFormField[] =>
+ Array.isArray(value);
+
+export const getPaymentFormFields = (formFieldsData: unknown): PaymentFormField[] =>
+ isPaymentFormFieldArray(formFieldsData) ? formFieldsData : [];
diff --git a/packages/payment-integration-api/src/index.ts b/packages/payment-integration-api/src/index.ts
index 6cd435ad36..4099046004 100644
--- a/packages/payment-integration-api/src/index.ts
+++ b/packages/payment-integration-api/src/index.ts
@@ -22,6 +22,7 @@ export {
export { SpecificError } from './errors';
export { getCountryData } from './CountryData';
export { CaptureMessageComponent } from './CaptureMessageComponent';
+export { getPaymentFormFields, type PaymentFormField } from './PaymentFormFields';
// export types separately
export type { CountryData } from './CountryData';