From 3fa59e17b51d4ab9c01869a499f4f379260656e0 Mon Sep 17 00:00:00 2001 From: anatolii-niagin-cko Date: Mon, 2 Nov 2026 15:09:27 +0100 Subject: [PATCH 1/4] feat(nativeapm): show customer instructions on the form view The legacy native APM client (used by Dynamic Checkout) showed the customer message and QR code only on the pending view. A form step can carry them too: Kkiapay Orange CI shows a QR code / payment link next to an optional OTP field. The new apm client and the mobile SDKs already render them. - The form view renders the message (markdown) and the QR code above the inputs when the step sends them; steps without instructions don't change. A rendering error is caught, so the form always shows. - The barcode is read from both API shapes: an object on GET native-payment and flat keys on process native-payment. - Links in the form message open in a new window (a separate showdown converter), so a payment link doesn't replace the merchant page. The pending view is unchanged. - Without the markdown library the message is plain text; an invalid base64 barcode draws no QR code. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/nativeapm/nativeapm.ts | 19 ++- src/nativeapm/references.ts | 1 + src/nativeapm/views/customer-instructions.ts | 121 ++++++++++++++++++ src/nativeapm/views/form.ts | 37 ++++++ test/nativeapm/customer-instructions.test.ts | 125 +++++++++++++++++++ test/support/loadNamespace.ts | 25 ++++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/nativeapm/views/customer-instructions.ts create mode 100644 test/nativeapm/customer-instructions.test.ts diff --git a/package.json b/package.json index 76bc687e..ce7cd406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.18", + "version": "1.9.19", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", diff --git a/src/nativeapm/nativeapm.ts b/src/nativeapm/nativeapm.ts index 9558e0f6..f89492bd 100644 --- a/src/nativeapm/nativeapm.ts +++ b/src/nativeapm/nativeapm.ts @@ -60,6 +60,13 @@ module ProcessOut { */ markdownLibraryInstance: any + /** + * Instance of JS library which handles markdown on the form view. + * Its links open in a new window, so a payment link doesn't replace the checkout page. + * @type {any} + */ + formMarkdownLibraryInstance: any + /** * Native APM container element for mounting the widget * @type {Element} @@ -185,11 +192,15 @@ module ProcessOut { ) { const nativeApmFormView = new NativeApmFormView( this.processOutInstance, - this.gatewayConfiguration.native_apm, + { + ...this.gatewayConfiguration.native_apm, + customerInstructions: this.gatewayConfiguration.native_apm.parameter_values, + }, this.proceedPayment.bind(this), this.theme, this.prefilledData, this.paymentConfig.payButtonText, + this.formMarkdownLibraryInstance, ) EventsUtils.dispatchWidgetReadyEvent() @@ -299,11 +310,13 @@ module ProcessOut { gateway: this.gatewayConfiguration.native_apm.gateway, parameters: data.native_apm.parameterDefinitions, invoice: this.gatewayConfiguration.native_apm.invoice, + customerInstructions: data.native_apm.parameterValues, }, this.proceedPayment.bind(this), this.theme, this.prefilledData, this.paymentConfig.payButtonText, + this.formMarkdownLibraryInstance, ) EventsUtils.dispatchPaymentAdditionalInputEvent() @@ -516,6 +529,10 @@ module ProcessOut { window.globalThis && window.globalThis.showdown ? new window.globalThis.showdown.Converter() : null + this.formMarkdownLibraryInstance = + window.globalThis && window.globalThis.showdown + ? new window.globalThis.showdown.Converter({ openLinksInNewWindow: true }) + : null } document.head.appendChild(markdownScript) } diff --git a/src/nativeapm/references.ts b/src/nativeapm/references.ts index b55316f9..92ac14c8 100644 --- a/src/nativeapm/references.ts +++ b/src/nativeapm/references.ts @@ -16,6 +16,7 @@ /// /// /// +/// /// /// /// diff --git a/src/nativeapm/views/customer-instructions.ts b/src/nativeapm/views/customer-instructions.ts new file mode 100644 index 00000000..7e447143 --- /dev/null +++ b/src/nativeapm/views/customer-instructions.ts @@ -0,0 +1,121 @@ +/// + +type NativeApmCustomerActionBarcode = { + type: string + value: string +} + +/** + * Customer instructions sent by the API in native APM parameter values. + * The barcode comes as an object from GET native-payment and as flat keys from process native-payment. + */ +type NativeApmCustomerInstructionsValues = { + customer_action_message?: string + customer_action_barcode?: NativeApmCustomerActionBarcode + customer_action_barcode_type?: string + customer_action_barcode_value?: string +} + +/** + * ProcessOut module/namespace + */ +module ProcessOut { + /** + * ProcessOut Native APM class for rendering customer instructions (message and QR code) + */ + export class NativeApmCustomerInstructions { + /** + * This function returns the customer action message, or an empty string + */ + static getMessage(values?: NativeApmCustomerInstructionsValues): string { + return values && values.customer_action_message ? values.customer_action_message : "" + } + + /** + * This function returns the customer action barcode from the object or the flat keys + */ + static getBarcode( + values?: NativeApmCustomerInstructionsValues, + ): NativeApmCustomerActionBarcode | undefined { + if (!values) { + return undefined + } + + if (values.customer_action_barcode && values.customer_action_barcode.value) { + return values.customer_action_barcode + } + + if (values.customer_action_barcode_value) { + return { + type: values.customer_action_barcode_type || "qr", + value: values.customer_action_barcode_value, + } + } + + return undefined + } + + /** + * This function creates the customer message element, rendered as markdown when the library is loaded + */ + static createMessageElement( + markdownLibraryInstance: any, + theme: NativeApmThemeConfigType, + message: string, + ) { + const customerMessage = document.createElement("p") + + customerMessage.setAttribute("class", "native-apm-message") + + StylesUtils.styleElement(customerMessage, theme.message) + + if (markdownLibraryInstance && markdownLibraryInstance.makeHtml) { + customerMessage.innerHTML = markdownLibraryInstance.makeHtml(message) + } else { + customerMessage.textContent = message + } + + return customerMessage + } + + /** + * This function creates the QR code element from a base64 encoded barcode value + */ + static createQrCodeElement( + theme: NativeApmThemeConfigType, + barcode?: NativeApmCustomerActionBarcode, + ) { + const qrCodeElement = document.createElement("div") + + qrCodeElement.setAttribute("class", "native-apm-qr-code") + + StylesUtils.styleElement(qrCodeElement, theme.actionImage) + + const text = + barcode && barcode.value + ? NativeApmCustomerInstructions.decodeBarcodeValue(barcode.value) + : null + + window.globalThis.QRCode && + text && + new window.globalThis.QRCode(qrCodeElement, { + text, + width: 128, + height: 128, + }) + + return qrCodeElement + } + + /** + * This function decodes the base64 barcode value. An invalid value draws no QR code instead of breaking the view. + */ + private static decodeBarcodeValue(value: string): string | null { + try { + return atob(value) + } catch { + return null + } + } + } +} diff --git a/src/nativeapm/views/form.ts b/src/nativeapm/views/form.ts index ba19348c..eef9a311 100644 --- a/src/nativeapm/views/form.ts +++ b/src/nativeapm/views/form.ts @@ -7,6 +7,7 @@ type NativeApmFormData = { amount: string currency_code: string } + customerInstructions?: NativeApmCustomerInstructionsValues } /** @@ -71,6 +72,12 @@ module ProcessOut { */ payButtonText?: string + /** + * Markdown library instance used to render the customer message + * @type {any} + */ + markdownLibraryInstance: any + /** * Native APM Form constructor */ @@ -81,6 +88,7 @@ module ProcessOut { theme: NativeApmThemeConfigType, prefilledData: PrefilledData, payButtonText?: string, + markdownLibraryInstance?: any, ) { if (!formData) { throw new Exception( @@ -94,6 +102,7 @@ module ProcessOut { this.prefilledData = prefilledData this.formData = formData this.payButtonText = payButtonText + this.markdownLibraryInstance = markdownLibraryInstance this.onSubmitHandler = onSubmitHandler this.formInputs = this.createFormInputs() this.submitButton = this.createSubmitButton() @@ -126,6 +135,7 @@ module ProcessOut { }) form.appendChild(paymentProviderImage) + this.appendCustomerInstructions(form) form.appendChild(inputsWrapper) form.appendChild(this.submitButton.getButtonElement()) @@ -134,6 +144,33 @@ module ProcessOut { return form } + /** + * This function appends the customer message and QR code sent with the step, if any. + * A rendering error must never break the form, so the instructions are skipped instead. + */ + private appendCustomerInstructions(form: HTMLFormElement) { + const message = NativeApmCustomerInstructions.getMessage(this.formData.customerInstructions) + const barcode = NativeApmCustomerInstructions.getBarcode(this.formData.customerInstructions) + + try { + if (message) { + form.appendChild( + NativeApmCustomerInstructions.createMessageElement( + this.markdownLibraryInstance, + this.theme, + message, + ), + ) + } + + if (barcode) { + form.appendChild(NativeApmCustomerInstructions.createQrCodeElement(this.theme, barcode)) + } + } catch (error) { + console.error("Failed to render native APM customer instructions", error) + } + } + /** * This function creates the form inputs */ diff --git a/test/nativeapm/customer-instructions.test.ts b/test/nativeapm/customer-instructions.test.ts new file mode 100644 index 00000000..c60c8bb8 --- /dev/null +++ b/test/nativeapm/customer-instructions.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest" +import { loadNativeApmCustomerInstructions } from "../support/loadNamespace" + +type FakeElement = { + tagName: string + attributes: Record + style: Record + innerHTML: string + textContent: string + setAttribute: (name: string, value: string) => void +} + +function createFakeDocument() { + return { + createElement: (tagName: string): FakeElement => { + const element: FakeElement = { + tagName, + attributes: {}, + style: {}, + innerHTML: "", + textContent: "", + setAttribute: (name, value) => { + element.attributes[name] = value + }, + } + return element + }, + } +} + +function load(QRCode?: unknown) { + const ns = loadNativeApmCustomerInstructions(createFakeDocument(), { globalThis: { QRCode } }) + return ns.NativeApmCustomerInstructions +} + +const theme = { message: { color: "black" }, actionImage: { margin: "8px" } } + +describe("NativeApmCustomerInstructions.getMessage", () => { + it("returns the customer action message", () => { + expect(load().getMessage({ customer_action_message: "Scan the QR code" })).toBe("Scan the QR code") + }) + + it("returns an empty string when there is no message", () => { + expect(load().getMessage(undefined)).toBe("") + expect(load().getMessage({})).toBe("") + }) +}) + +describe("NativeApmCustomerInstructions.getBarcode", () => { + it("reads the barcode object sent by GET native-payment", () => { + const barcode = { type: "qr", value: "aGVsbG8=" } + + expect(load().getBarcode({ customer_action_barcode: barcode })).toEqual(barcode) + }) + + it("reads the flat barcode keys sent by process native-payment", () => { + expect( + load().getBarcode({ + customer_action_barcode_type: "qr", + customer_action_barcode_value: "aGVsbG8=", + }), + ).toEqual({ type: "qr", value: "aGVsbG8=" }) + }) + + it("defaults the flat barcode type to qr", () => { + expect(load().getBarcode({ customer_action_barcode_value: "aGVsbG8=" })).toEqual({ + type: "qr", + value: "aGVsbG8=", + }) + }) + + it("returns undefined when there is no barcode value", () => { + expect(load().getBarcode(undefined)).toBeUndefined() + expect(load().getBarcode({ customer_action_message: "text" })).toBeUndefined() + expect(load().getBarcode({ customer_action_barcode: { type: "qr", value: "" } })).toBeUndefined() + }) +}) + +describe("NativeApmCustomerInstructions.createMessageElement", () => { + it("renders markdown when the library is loaded", () => { + const markdown = { makeHtml: vi.fn(() => "Pay") } + + const element = load().createMessageElement(markdown, theme, "[Pay](https://example.com)") + + expect(markdown.makeHtml).toHaveBeenCalledWith("[Pay](https://example.com)") + expect(element.innerHTML).toBe("Pay") + expect(element.attributes.class).toBe("native-apm-message") + expect(element.style.color).toBe("black") + }) + + it("renders plain text when the library is not loaded", () => { + const element = load().createMessageElement(null, theme, "not html") + + expect(element.textContent).toBe("not html") + expect(element.innerHTML).toBe("") + }) +}) + +describe("NativeApmCustomerInstructions.createQrCodeElement", () => { + it("draws the QR code from the decoded barcode value", () => { + const QRCode = vi.fn() + + const element = load(QRCode).createQrCodeElement(theme, { type: "qr", value: btoa("https://pay.example") }) + + expect(QRCode).toHaveBeenCalledWith(element, { text: "https://pay.example", width: 128, height: 128 }) + expect(element.style.margin).toBe("8px") + }) + + it("draws nothing when the barcode value is not base64", () => { + const QRCode = vi.fn() + + load(QRCode).createQrCodeElement(theme, { type: "qr", value: "not base64!" }) + + expect(QRCode).not.toHaveBeenCalled() + }) + + it("draws nothing without a barcode or the library", () => { + const QRCode = vi.fn() + + load(QRCode).createQrCodeElement(theme, undefined) + load(undefined).createQrCodeElement(theme, { type: "qr", value: btoa("https://pay.example") }) + + expect(QRCode).not.toHaveBeenCalled() + }) +}) diff --git a/test/support/loadNamespace.ts b/test/support/loadNamespace.ts index b1ef93dd..fcc827b0 100644 --- a/test/support/loadNamespace.ts +++ b/test/support/loadNamespace.ts @@ -167,3 +167,28 @@ export function loadTelemetry(): Record { run(moduleShim, moduleShim.exports) return moduleShim.exports } + +/** + * Load the native APM customer instructions helper (with `StylesUtils`, which it uses) and return its + * `ProcessOut` namespace. `document` and `window` are injected so the DOM helpers run without a browser. + */ +export function loadNativeApmCustomerInstructions( + document: Record, + window: Record, +): Record { + const js = [ + compile("src/nativeapm/utils/styles.ts"), + `const { StylesUtils } = ProcessOut;`, + compile("src/nativeapm/views/customer-instructions.ts"), + ].join("\n") + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + "document", + "window", + `${js}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, document, window) + return moduleShim.exports +} From b484a6292aaa4b64060c54e9c1e7d00cbeacb8ee Mon Sep 17 00:00:00 2001 From: anatolii-niagin-cko Date: Mon, 2 Nov 2026 15:56:26 +0100 Subject: [PATCH 2/4] fix(nativeapm): draw the form instructions again once the libraries load Review follow-up: a form rendered before the markdown or QR code library loaded kept raw markdown or an empty QR code. The instructions are now drawn again once both libraries are loaded (or failed to load); the inputs are there from the start. Forms without instructions get no container. Co-Authored-By: Claude Opus 5.5 --- src/nativeapm/nativeapm.ts | 42 +++++++++++++++++++++++++++++ src/nativeapm/views/form.ts | 53 +++++++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/nativeapm/nativeapm.ts b/src/nativeapm/nativeapm.ts index f89492bd..140e2ec2 100644 --- a/src/nativeapm/nativeapm.ts +++ b/src/nativeapm/nativeapm.ts @@ -109,6 +109,18 @@ module ProcessOut { */ captureStart = null + /** + * Markdown and QR code libraries still loading + * @type {number} + */ + pendingInstructionLibraries = 2 + + /** + * Callbacks to run once the markdown and QR code libraries are loaded (or failed to load) + * @type {Function[]} + */ + instructionLibrariesCallbacks: Function[] = [] + /** * NativeAPM constructor * @param {NativeApmConfig} config @@ -204,6 +216,7 @@ module ProcessOut { ) EventsUtils.dispatchWidgetReadyEvent() + this.refreshInstructionsWhenLibrariesLoad(nativeApmFormView) return this.loadView(nativeApmFormView.getViewElement()) } @@ -320,6 +333,7 @@ module ProcessOut { ) EventsUtils.dispatchPaymentAdditionalInputEvent() + this.refreshInstructionsWhenLibrariesLoad(customerInputView) return this.loadView(customerInputView.getViewElement()) } @@ -533,15 +547,43 @@ module ProcessOut { window.globalThis && window.globalThis.showdown ? new window.globalThis.showdown.Converter({ openLinksInNewWindow: true }) : null + this.markInstructionLibraryLoaded() } + markdownScript.onerror = () => this.markInstructionLibraryLoaded() document.head.appendChild(markdownScript) } private loadQrCodesLibrary() { const qrCodeScript = document.createElement("script") qrCodeScript.src = this.processOutInstance.endpoint("js", "/js/libraries/qrcode.min.js") + qrCodeScript.onload = () => this.markInstructionLibraryLoaded() + qrCodeScript.onerror = () => this.markInstructionLibraryLoaded() document.head.appendChild(qrCodeScript) } + + private markInstructionLibraryLoaded() { + this.pendingInstructionLibraries-- + if (this.pendingInstructionLibraries > 0) { + return + } + + const callbacks = this.instructionLibrariesCallbacks + this.instructionLibrariesCallbacks = [] + callbacks.forEach(callback => callback()) + } + + /** + * A form can render before the markdown and QR code libraries are loaded: its instructions are drawn again then + */ + private refreshInstructionsWhenLibrariesLoad(formView: NativeApmFormView) { + if (this.pendingInstructionLibraries <= 0) { + return + } + + this.instructionLibrariesCallbacks.push(() => + formView.refreshCustomerInstructions(this.formMarkdownLibraryInstance), + ) + } private loadPhoneNumberInputLibrary() { const phoneInputScript = document.createElement("script") phoneInputScript.src = this.processOutInstance.endpoint( diff --git a/src/nativeapm/views/form.ts b/src/nativeapm/views/form.ts index eef9a311..abb98c14 100644 --- a/src/nativeapm/views/form.ts +++ b/src/nativeapm/views/form.ts @@ -78,6 +78,12 @@ module ProcessOut { */ markdownLibraryInstance: any + /** + * Container of the customer message and QR code + * @type {HTMLElement} + */ + instructionsElement: HTMLElement + /** * Native APM Form constructor */ @@ -135,7 +141,9 @@ module ProcessOut { }) form.appendChild(paymentProviderImage) - this.appendCustomerInstructions(form) + if (this.hasCustomerInstructions()) { + form.appendChild(this.createInstructionsElement()) + } form.appendChild(inputsWrapper) form.appendChild(this.submitButton.getButtonElement()) @@ -145,16 +153,49 @@ module ProcessOut { } /** - * This function appends the customer message and QR code sent with the step, if any. + * This function draws the customer instructions again, once the markdown and QR code libraries are loaded + */ + public refreshCustomerInstructions(markdownLibraryInstance: any) { + if (!this.instructionsElement) { + return + } + + this.markdownLibraryInstance = markdownLibraryInstance + this.instructionsElement.innerHTML = "" + this.renderCustomerInstructions() + } + + /** + * This function checks whether the step sent a customer message or a QR code + */ + private hasCustomerInstructions() { + return ( + !!NativeApmCustomerInstructions.getMessage(this.formData.customerInstructions) || + !!NativeApmCustomerInstructions.getBarcode(this.formData.customerInstructions) + ) + } + + /** + * This function creates the container of the customer message and QR code sent with the step + */ + private createInstructionsElement() { + this.instructionsElement = document.createElement("div") + this.instructionsElement.setAttribute("class", "native-apm-instructions") + this.renderCustomerInstructions() + + return this.instructionsElement + } + + /** * A rendering error must never break the form, so the instructions are skipped instead. */ - private appendCustomerInstructions(form: HTMLFormElement) { + private renderCustomerInstructions() { const message = NativeApmCustomerInstructions.getMessage(this.formData.customerInstructions) const barcode = NativeApmCustomerInstructions.getBarcode(this.formData.customerInstructions) try { if (message) { - form.appendChild( + this.instructionsElement.appendChild( NativeApmCustomerInstructions.createMessageElement( this.markdownLibraryInstance, this.theme, @@ -164,7 +205,9 @@ module ProcessOut { } if (barcode) { - form.appendChild(NativeApmCustomerInstructions.createQrCodeElement(this.theme, barcode)) + this.instructionsElement.appendChild( + NativeApmCustomerInstructions.createQrCodeElement(this.theme, barcode), + ) } } catch (error) { console.error("Failed to render native APM customer instructions", error) From 04bf30c9ace41b8c7a97d90aec2f9dc1091fd40b Mon Sep 17 00:00:00 2001 From: anatolii-niagin-cko Date: Wed, 4 Nov 2026 11:28:08 +0100 Subject: [PATCH 3/4] chore: bump version to 1.9.20 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce7cd406..a20fd093 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.19", + "version": "1.9.20", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", From 9edb23a2ad0365c376b1803ca528ac1cf08a1464 Mon Sep 17 00:00:00 2001 From: anatolii-niagin-cko Date: Thu, 5 Nov 2026 18:07:46 +0100 Subject: [PATCH 4/4] fix(POINT-3675)(nativeapm): center action image/QR in native APM views The action image / QR sat flush-left: it was a fixed-width (15rem) block with no horizontal centering. Add auto side margins so it centers within its container on the form, pending and success views. Co-Authored-By: Claude Opus 4.8 --- src/nativeapm/config/theme.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/nativeapm/config/theme.ts b/src/nativeapm/config/theme.ts index 3a41bcbd..f5a14525 100644 --- a/src/nativeapm/config/theme.ts +++ b/src/nativeapm/config/theme.ts @@ -255,6 +255,9 @@ const defaultTheme: NativeApmThemeConfigType = { actionImage: { marginTop: "1.3rem", marginBottom: "1.3rem", + // auto side margins center the fixed-width action image/QR within its container + marginLeft: "auto", + marginRight: "auto", width: "15rem", height: "auto", display: "flex",