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",
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",
diff --git a/src/nativeapm/nativeapm.ts b/src/nativeapm/nativeapm.ts
index 9558e0f6..140e2ec2 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}
@@ -102,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
@@ -185,14 +204,19 @@ 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()
+ this.refreshInstructionsWhenLibrariesLoad(nativeApmFormView)
return this.loadView(nativeApmFormView.getViewElement())
}
@@ -299,14 +323,17 @@ 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()
+ this.refreshInstructionsWhenLibrariesLoad(customerInputView)
return this.loadView(customerInputView.getViewElement())
}
@@ -516,15 +543,47 @@ 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
+ 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/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..abb98c14 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,18 @@ module ProcessOut {
*/
payButtonText?: string
+ /**
+ * Markdown library instance used to render the customer message
+ * @type {any}
+ */
+ markdownLibraryInstance: any
+
+ /**
+ * Container of the customer message and QR code
+ * @type {HTMLElement}
+ */
+ instructionsElement: HTMLElement
+
/**
* Native APM Form constructor
*/
@@ -81,6 +94,7 @@ module ProcessOut {
theme: NativeApmThemeConfigType,
prefilledData: PrefilledData,
payButtonText?: string,
+ markdownLibraryInstance?: any,
) {
if (!formData) {
throw new Exception(
@@ -94,6 +108,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 +141,9 @@ module ProcessOut {
})
form.appendChild(paymentProviderImage)
+ if (this.hasCustomerInstructions()) {
+ form.appendChild(this.createInstructionsElement())
+ }
form.appendChild(inputsWrapper)
form.appendChild(this.submitButton.getButtonElement())
@@ -134,6 +152,68 @@ module ProcessOut {
return form
}
+ /**
+ * 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 renderCustomerInstructions() {
+ const message = NativeApmCustomerInstructions.getMessage(this.formData.customerInstructions)
+ const barcode = NativeApmCustomerInstructions.getBarcode(this.formData.customerInstructions)
+
+ try {
+ if (message) {
+ this.instructionsElement.appendChild(
+ NativeApmCustomerInstructions.createMessageElement(
+ this.markdownLibraryInstance,
+ this.theme,
+ message,
+ ),
+ )
+ }
+
+ if (barcode) {
+ this.instructionsElement.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 969e096c..b028f53b 100644
--- a/test/support/loadNamespace.ts
+++ b/test/support/loadNamespace.ts
@@ -168,6 +168,31 @@ export function loadTelemetry(): Record {
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
+}
+
export interface FakeElement {
tagName: string
attributes: Record