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