diff --git a/docs/apm-ui-system.md b/docs/apm-ui-system.md index a44a37b2..50b86e52 100644 --- a/docs/apm-ui-system.md +++ b/docs/apm-ui-system.md @@ -473,6 +473,12 @@ class PaymentFormView extends APMViewImpl<{}, { render() { const { div, input, button, label } = elements; + + let buttonText = 'Continue payment' + + if (loading) { + buttonText = 'Processing...'; + } return div({ className: 'payment-form' }, div({ className: 'form-group' }, @@ -500,7 +506,7 @@ class PaymentFormView extends APMViewImpl<{}, { onclick: this.handleSubmit, disabled: this.state.loading || !this.state.email || !this.state.agreedToTerms }, - this.state.loading ? 'Processing...' : 'Continue Payment' + buttonText ) ); } diff --git a/package.json b/package.json index e85d15fa..ab5bfaf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.2.3", + "version": "1.2.4", "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/apm/API.ts b/src/apm/API.ts index 7a017b1b..14cbe83c 100644 --- a/src/apm/API.ts +++ b/src/apm/API.ts @@ -238,7 +238,7 @@ module ProcessOut { } ContextImpl.context.logger.error({ - host: window?.location?.host ?? '', + host: window && window.location && window.location.host || '', fileName: 'API.ts', lineNumber: 208, message, @@ -254,14 +254,14 @@ module ProcessOut { } }; - options.onFailure?.(defaultError); + options.onFailure && options.onFailure(defaultError); return } switch (data.error_type) { case 'request.route-not-found': ContextImpl.context.logger.error({ - host: window?.location?.host ?? '', + host: window && window.location && window.location.host || '', fileName: 'API.ts', lineNumber: 208, message: `${request} failed as route does not exist`, @@ -277,11 +277,11 @@ module ProcessOut { } }; - options.onFailure?.(routeNotFoundError); + options.onFailure && options.onFailure(routeNotFoundError); break; default: { ContextImpl.context.logger.error({ - host: window?.location?.host ?? '', + host: window && window.location && window.location.host || '', fileName: 'API.ts', lineNumber: 208, message: `${request} failed because of an error: ${data.message}`, @@ -297,7 +297,7 @@ module ProcessOut { } }; - options.onFailure?.(defaultError); + options.onFailure && options.onFailure(defaultError); break; } } @@ -310,7 +310,12 @@ module ProcessOut { public static initialise(options: APIOptions) { const context = ContextImpl.context; const flow = context.flow; - const source = flow === 'authorization' ? context.customerTokenId : undefined; + let source = undefined; + + if (flow === 'authorization') { + source = context.customerTokenId; + } + return this.post({ gateway_configuration_id: context.gatewayConfigurationId, @@ -324,7 +329,11 @@ module ProcessOut { public static getCurrentStep(options: APIOptions) { const context = ContextImpl.context; const flow = context.flow; - const source = flow === 'authorization' ? context.customerTokenId : undefined; + let source = undefined; + + if (flow === 'authorization') { + source = context.customerTokenId; + } return this.post({ gateway_configuration_id: context.gatewayConfigurationId, @@ -389,12 +398,14 @@ module ProcessOut { const context = ContextImpl.context; // Build endpoint based on flow type - let endpoint = context.flow === 'authorization' - ? ['invoices', context.invoiceId, 'apm-payment', path].filter(part => !!part).join('/') - : ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/'); + let endpoint = ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/'); + + if (context.flow === 'authorization') { + endpoint = ['invoices', context.invoiceId, 'apm-payment', path].filter(part => !!part).join('/') - if (context.customerTokenId && context.flow === 'authorization' && method === 'GET') { - endpoint += `?source=${context.customerTokenId}` + if (context.customerTokenId && method === 'GET') { + endpoint += `?source=${context.customerTokenId}` + } } ContextImpl.context.poClient.apiRequest( @@ -416,10 +427,12 @@ module ProcessOut { } // Handle validation responses based on flow type - const isValidation = context.flow === 'authorization' - ? isValidationResponse(apiResponse as AuthorizationNetworkResponse) - : isTokenizationValidationResponse(apiResponse as TokenizationNetworkResponse); + let isValidation = isTokenizationValidationResponse(apiResponse as TokenizationNetworkResponse); + if (context.flow === 'authorization') { + isValidation = isValidationResponse(apiResponse as AuthorizationNetworkResponse); + } + if (isValidation) { INITIAL_MAX_RETRIES = 0; @@ -437,7 +450,7 @@ module ProcessOut { error: { code: 'processout-js.apm.validation-error', message: 'Validation error', - invalid_fields: (apiResponse as any).invalid_fields || Object.keys((apiResponse as any).error?.parameters || {}).reduce((acc, name) => { + invalid_fields: (apiResponse as any).invalid_fields || Object.keys((apiResponse as any).error && (apiResponse as any).error.parameters || {}).reduce((acc, name) => { acc.push({ name, message: (apiResponse as any).error.parameters[name].detail @@ -449,7 +462,7 @@ module ProcessOut { // Add all payment fields (PaymentContext + payment data) const errorWithPaymentData = this.addPaymentFields(errorData, apiResponse); - internalOptions.onError?.(errorWithPaymentData as any); + internalOptions.onError && internalOptions.onError(errorWithPaymentData as any); return; } @@ -480,7 +493,7 @@ module ProcessOut { // Include payment data in timeout error const timeoutErrorWithPaymentData = this.addPaymentFields(timeoutError, apiResponse); - internalOptions.onFailure?.(timeoutErrorWithPaymentData); + internalOptions.onFailure && internalOptions.onFailure(timeoutErrorWithPaymentData); return; } } @@ -493,7 +506,7 @@ module ProcessOut { internalOptions.hasReturnedFirstPending = true; } - internalOptions.onSuccess?.(this.transformResponse(apiResponse)); + internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(apiResponse)); if (ContextImpl.context.confirmation.requiresAction && !storage.get('pending.startTime')) { INITIAL_MAX_RETRIES = 0; return @@ -528,7 +541,7 @@ module ProcessOut { } if (apiResponse.state === 'NEXT_STEP_REQUIRED' && apiResponse.redirect) { - internalOptions.onSuccess?.(this.transformResponse( + internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse( { ...apiResponse, state: 'REDIRECT', @@ -537,7 +550,7 @@ module ProcessOut { return } - internalOptions.onSuccess?.(this.transformResponse(apiResponse)); + internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(apiResponse)); return; }, (req, _, errorCode) => { @@ -568,10 +581,13 @@ module ProcessOut { }; // Include payment data in network error if available - const networkErrorWithPaymentData = req.response - ? this.addPaymentFields(networkError, req.response) - : networkError; - internalOptions.onFailure?.(networkErrorWithPaymentData); + let networkErrorWithPaymentData = networkError; + + if (req.response) { + networkErrorWithPaymentData = this.addPaymentFields(networkError, req.response) + } + + internalOptions.onFailure && internalOptions.onFailure(networkErrorWithPaymentData); } ); } diff --git a/src/apm/Page.ts b/src/apm/Page.ts index 9626914e..d542e594 100644 --- a/src/apm/Page.ts +++ b/src/apm/Page.ts @@ -46,13 +46,17 @@ module ProcessOut { return; } + let hasConfirmedPending = true; + + if (ContextImpl.context.confirmation.requiresAction) { + hasConfirmedPending = this.state === "PENDING" + } + (request.bind(APIImpl) as APIRequest)({ - hasConfirmedPending: ContextImpl.context.confirmation.requiresAction - ? this.state === "PENDING" - : true, + hasConfirmedPending, onSuccess: ({ elements, ...config }) => { this.state = config.state - callback?.(null, this.state); + callback && callback(null, this.state); if (config.state === 'REDIRECT') { ContextImpl.context.page.render(APMViewRedirect, { elements, config: config as APIRedirectBase & Partial }) @@ -74,7 +78,7 @@ module ProcessOut { }, onError: ({ elements, ...config }) => { this.state = config.state - callback?.(config.error); + callback && callback(config.error); ContextImpl.context.page.render(APMViewNextSteps, { elements, config }) }, onFailure: data => { @@ -122,7 +126,7 @@ module ProcessOut { loadScript(name: string, path: string, callback?: (error?: Error) => void): void { // Check if script is already loaded if (this.loadedScripts.get(name)) { - callback?.(); + callback && callback(); return; } @@ -143,7 +147,7 @@ module ProcessOut { // Check if script already exists in the document if (document.querySelector(`script[src*="${name}"]`)) { this.loadedScripts.set(name, true); - callback?.(); + callback && callback(); return; } @@ -152,7 +156,14 @@ module ProcessOut { // Create and load the script const script = document.createElement('script'); - script.src = path.startsWith('https://') ? path : ContextImpl.context.poClient.endpoint("js", path); + + let scriptPath = path; + + if (!path.startsWith('https://')) { + scriptPath = ContextImpl.context.poClient.endpoint("js", path); + } + + script.src = scriptPath; script.onload = () => { this.loadedScripts.set(name, true); @@ -222,18 +233,23 @@ module ProcessOut { // --- Create New Wrapper based on support --- if (!supportsShadowDOM) { // Fallback: Use an iframe if Shadow DOM is not supported - const height = container.getBoundingClientRect().height; + let height = container.getBoundingClientRect().height; const iframe = document.createElement('iframe'); + + if (height < 400) { + height = 400; + } + iframe.setAttribute('frameBorder', '0'); iframe.style.width = '100%'; - iframe.style.height = height < 400 ? '400px' : height + 'px'; + iframe.style.height = height + 'px'; iframe.title = 'Content Wrapper'; // Good practice for accessibility container.appendChild(iframe); // Append iframe directly to the user's container // Setup iframe content after it's loaded to avoid race conditions const setupIframeContent = () => { - const doc = iframe.contentDocument ?? iframe.contentWindow?.document; + const doc = iframe.contentDocument || iframe.contentWindow && iframe.contentWindow.document; if (doc) { // Ensure the iframe has a basic HTML structure if it's not fully loaded diff --git a/src/apm/StateManager.ts b/src/apm/StateManager.ts index b0ac3dfa..3ab3bd92 100644 --- a/src/apm/StateManager.ts +++ b/src/apm/StateManager.ts @@ -98,8 +98,13 @@ module ProcessOut { * Get component state by ID */ getComponentState(id: string): T | null { - const component = this.componentStates[id]; - return component ? component.data : null; + let component = this.componentStates[id] as T; + + if (!component) { + component = null + } + + return component; } /** @@ -120,9 +125,11 @@ module ProcessOut { } // Calculate new state - const updatedState = typeof newState === 'function' - ? (newState as (prevState: T) => T)(component.data) - : newState; + let updatedState = newState; + + if (typeof updatedState === 'function') { + updatedState = (newState as (prevState: T) => T)(component.data) + } // Check if state actually changed (shallow comparison) const stateChanged = forceUpdate || !this.shallowEqual(component.data, updatedState); @@ -164,10 +171,12 @@ module ProcessOut { this.isBatchScheduled = true; // Use requestAnimationFrame to batch updates, with fallback for IE 11 - const scheduleFunction = (typeof requestAnimationFrame !== 'undefined') - ? requestAnimationFrame - : function(callback: () => void) { setTimeout(callback, 16); }; - + let scheduleFunction = requestAnimationFrame; + + if (!scheduleFunction) { + scheduleFunction = function(callback: FrameRequestCallback) { return setTimeout(() => callback(performance.now()), 16); }; + } + scheduleFunction(() => { this.processBatchUpdate(); }); @@ -205,10 +214,12 @@ module ProcessOut { this.pendingCallbacks.length = 0; if (callbacks.length > 0) { - const scheduleFunction = (typeof requestAnimationFrame !== 'undefined') - ? requestAnimationFrame - : function(callback: () => void) { setTimeout(callback, 16); }; - + let scheduleFunction = requestAnimationFrame + + if (!scheduleFunction) { + scheduleFunction = function(callback: FrameRequestCallback) { return setTimeout(() => callback(performance.now()), 16); }; + } + scheduleFunction(() => { for (let i = 0; i < callbacks.length; i++) { try { @@ -595,7 +606,11 @@ module ProcessOut { // Find the next available collision number let collisionNum = viewCounters[baseHash]; - let candidateId = collisionNum === 0 ? baseHash : `${baseHash}-${collisionNum}`; + let candidateId = `${baseHash}-${collisionNum}`; + + if (collisionNum === 0) { + candidateId = baseHash; + } // If this collision number is already taken, find next available while (existingIds.has(candidateId)) { @@ -628,9 +643,11 @@ module ProcessOut { const stateManager = StateManager.getInstance(); // Generate component ID - use content-based if signature provided, fallback to call order - const componentId = signature - ? generateContentBasedComponentId(signature) - : generateAutoComponentId(); + let componentId = generateAutoComponentId(); + + if (signature) { + componentId = generateContentBasedComponentId(signature); + } // Get current view from context const currentView = getCurrentViewContext().currentView; diff --git a/src/apm/Theme.ts b/src/apm/Theme.ts index d593930e..612994dc 100644 --- a/src/apm/Theme.ts +++ b/src/apm/Theme.ts @@ -310,9 +310,13 @@ module ProcessOut { */ public static getCurrentColorScheme(): 'light' | 'dark' { if (typeof window !== 'undefined' && window.matchMedia) { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (isDark) { + return 'dark'; + } } - return 'light'; // Default fallback + return 'light'; } /** @@ -322,7 +326,11 @@ module ProcessOut { const scheme = ThemeImpl.getCurrentColorScheme(); const fullPath = `palette.${scheme}.${path}` as any; const value = ThemeImpl.instance.get(fullPath); - return typeof value === 'string' ? value : '#000000'; + if (typeof value === 'string') { + return value; + } else { + return '#000000'; + } } /** @@ -336,7 +344,11 @@ module ProcessOut { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e: MediaQueryListEvent) => { - callback(e.matches ? 'dark' : 'light'); + if (e.matches) { + callback('dark'); + } else { + callback('light'); + } }; if (mediaQuery && typeof mediaQuery.addEventListener === 'function') { @@ -377,7 +389,12 @@ module ProcessOut { const hexColor = this.recursiveFind(path, this.theme) // 1. Remove the '#' if it's there - const sanitizedHex = hexColor.startsWith('#') ? hexColor.slice(1) : hexColor; + let sanitizedHex; + if (hexColor.startsWith('#')) { + sanitizedHex = hexColor.slice(1); + } else { + sanitizedHex = hexColor; + } // 2. Handle shorthand hex codes (e.g., "03F" -> "0033FF") const fullHex = sanitizedHex.length === 3 @@ -398,7 +415,11 @@ module ProcessOut { // 5. Decide on the text color based on a luminance threshold. // If the background is bright (luminance > 140), use black text. // If the background is dark (luminance <= 139), use white text. - return luminance > 140 ? ThemeImpl.instance.get('palette.light.text.default') : ThemeImpl.instance.get('palette.dark.text.default') ; + if (luminance > 140) { + return ThemeImpl.instance.get('palette.light.text.default'); + } else { + return ThemeImpl.instance.get('palette.dark.text.default'); + } } public update(theme: DeepPartial) { @@ -503,7 +524,13 @@ module ProcessOut { @media (prefers-color-scheme: dark) { background-color: ${ThemeImpl.instance.get(`palette.dark.surface.button.hover.${color}`)}; - color: ${color === 'danger' ? ThemeImpl.instance.getTextColor('palette.light.text.default') : ThemeImpl.instance.getTextColor(`palette.dark.surface.button.hover.${color}`)}; + color: ${(() => { + if (color === 'danger') { + return ThemeImpl.instance.getTextColor('palette.light.text.default'); + } else { + return ThemeImpl.instance.getTextColor(`palette.dark.surface.button.hover.${color}`); + } + })()}; } } `() diff --git a/src/apm/elements/button.ts b/src/apm/elements/button.ts index 14c21827..81170d41 100644 --- a/src/apm/elements/button.ts +++ b/src/apm/elements/button.ts @@ -7,16 +7,26 @@ module ProcessOut { } export const Button = (first: ButtonProps | Child, ...children: Child[]) => { - const { className, variant, size, loading, disabled, ...userProps } = isProps(first) ? first : {} - let rest = [div({ className: "content" }, isProps(first) ? children : [first, ...children])]; + let userProps, buttonChildren; + + if (isProps(first)) { + userProps = first; + buttonChildren = children; + } else { + userProps = {}; + buttonChildren = [first, ...children]; + } + + const { className, variant, size, loading, disabled, ...otherProps } = userProps; + let rest = [div({ className: "content" }, buttonChildren)]; if (loading) { rest = [Loader()] } - const classNames = ["button", size ?? 'lg', variant ?? 'primary', loading && 'loading', disabled && 'disabled', className, ].filter(Boolean) + const classNames = ["button", size || 'lg', variant || 'primary', loading && 'loading', disabled && 'disabled', className, ].filter(Boolean) - const props = mergeProps<'button'>({ className: classNames.join(' '), disabled: disabled || loading}, userProps); + const props = mergeProps<'button'>({ className: classNames.join(' '), disabled: disabled || loading}, otherProps); return button(props, ...rest) } diff --git a/src/apm/elements/cancel-button.ts b/src/apm/elements/cancel-button.ts index 96c79ce3..1d2b5a39 100644 --- a/src/apm/elements/cancel-button.ts +++ b/src/apm/elements/cancel-button.ts @@ -3,7 +3,7 @@ module ProcessOut { export const CancelButton = ({ onClick, config }: { onClick?: () => void, config: APISuccessBase & Partial }) => { const onCancelClick = () => { - onClick?.() + onClick && onClick() ContextImpl.context.events.emit('request-cancel') ContextImpl.context.page.render(APMViewCancelRequest, { config }) } diff --git a/src/apm/elements/header.ts b/src/apm/elements/header.ts index 4cc9af6d..01fa9971 100644 --- a/src/apm/elements/header.ts +++ b/src/apm/elements/header.ts @@ -7,8 +7,16 @@ module ProcessOut { type HeaderArgs = [HeaderProps | string, string?] export const Header = (...args: HeaderArgs) => { const first = args[0] - const content: string = isProps(first) ? args[1] : first; - const props: HeaderTagProps = isProps(first) ? first : {} as HeaderTagProps + let content: string; + let props: HeaderTagProps; + + if (isProps(first)) { + content = args[1]; + props = first; + } else { + content = first; + props = {} as HeaderTagProps; + } const tag: HeaderTag = props.tag || 'h1'; delete props.tag diff --git a/src/apm/elements/otp.ts b/src/apm/elements/otp.ts index 2661cb7c..05b50315 100644 --- a/src/apm/elements/otp.ts +++ b/src/apm/elements/otp.ts @@ -33,7 +33,7 @@ module ProcessOut { const isCurrentlyComplete = state.values.every(v => v); if (isCurrentlyComplete && !value) { - onComplete?.(name, state.values.join('')); + onComplete && onComplete(name, state.values.join('')); } /** * Synchronizes the DOM to match the current state. This function is the single @@ -61,12 +61,17 @@ module ProcessOut { */ const handlePaste = (index: number, e: ClipboardEvent): void => { e.preventDefault(); - const pastedText = e.clipboardData?.getData('text') || ''; + const pastedText = e.clipboardData && e.clipboardData.getData('text') || ''; const currentValue = pastedText.trim(); const isNumeric = type === 'numeric'; // Handle pasting a full code - const cleaned = isNumeric ? currentValue.replace(/[^0-9]/g, '') : currentValue; + let cleaned; + if (isNumeric) { + cleaned = currentValue.replace(/[^0-9]/g, ''); + } else { + cleaned = currentValue; + } if (cleaned.length === length) { update({ @@ -87,7 +92,12 @@ module ProcessOut { // Handle autocomplete/multiple characters (like SMS OTP autocomplete) if (currentValue.length === length) { - const cleaned = isNumeric ? currentValue.replace(/[^0-9]/g, '') : currentValue; + let cleaned; + if (isNumeric) { + cleaned = currentValue.replace(/[^0-9]/g, ''); + } else { + cleaned = currentValue; + } // If it's a full OTP code, distribute it across all inputs if (cleaned.length === length) { @@ -123,7 +133,12 @@ module ProcessOut { // Handle single character input const char = currentValue[0]; - const isAllowed = isNumeric ? /^[0-9]$/.test(char) : true; + let isAllowed; + if (isNumeric) { + isAllowed = /^[0-9]$/.test(char); + } else { + isAllowed = true; + } let newValues = [...state.values]; let newFocusedIndex = state.focusedIndex; @@ -177,12 +192,30 @@ module ProcessOut { const handleHiddenFocus = (e: FocusEvent): void => { e.preventDefault() - inputRefs[state.focusedIndex]?.focus(); + inputRefs[state.focusedIndex] && inputRefs[state.focusedIndex].focus(); }; inputRefs.length = 0; const inputs = new Array(length).fill(0).map((_, i) => { + let maxlength, pattern, autocomplete, inputMode; + + if (i === 0) { + maxlength = undefined; + autocomplete = "one-time-code"; + } else { + maxlength = 1; + autocomplete = "off"; + } + + if (type === "numeric") { + pattern = "\\d*"; + inputMode = "numeric"; + } else { + pattern = undefined; + inputMode = undefined; + } + return Input({ name: `${name}-${i + 1}`, oninput: (_, value: string) => handleOnChange(i, value), @@ -193,10 +226,10 @@ module ProcessOut { value: state.values[i], id: `${name}-${i + 1}`, type: "text", // Use 'text' to allow input, pattern for numbers - maxlength: i === 0 ? undefined : 1, // First input allows autocomplete, others limited to 1 char - pattern: type === "numeric" ? "\\d*" : undefined, - autocomplete: i === 0 ? "one-time-code" : "off", - inputMode: type === "numeric" ? "numeric" : undefined, + maxlength: maxlength, + pattern: pattern, + autocomplete: autocomplete, + inputMode: inputMode, ref: liveNode => { if (liveNode) { inputRefs[i] = liveNode @@ -206,8 +239,15 @@ module ProcessOut { }); // Return the final element tree. + let labelElement; + if (label) { + labelElement = Header({ title: label, tag: 'label', className: 'otp-label', htmlFor: name }, label); + } else { + labelElement = null; + } + return div({ className: 'otp-container' }, - label ? Header({ title: label, tag: 'label', className: 'otp-label', htmlFor: name }, label) : null, + labelElement, div( labelEl( { className: 'otp', htmlFor: name }, diff --git a/src/apm/elements/page.ts b/src/apm/elements/page.ts index abe3a0d7..b5771c69 100644 --- a/src/apm/elements/page.ts +++ b/src/apm/elements/page.ts @@ -5,8 +5,15 @@ module ProcessOut { const first = args[0]; const children = args.slice(1) as Child[]; - const userProps = isProps(first) ? first : {} - const rest = isProps(first) ? children : [first, ...children]; + let userProps, rest; + + if (isProps(first)) { + userProps = first; + rest = children; + } else { + userProps = {}; + rest = [first, ...children]; + } const props = mergeProps<'div'>({ className: "page" }, userProps); diff --git a/src/apm/elements/phone.ts b/src/apm/elements/phone.ts index 02ea46a4..13125ee6 100644 --- a/src/apm/elements/phone.ts +++ b/src/apm/elements/phone.ts @@ -60,8 +60,8 @@ module ProcessOut { export const Phone = ({ dialing_codes, name, oninput, onblur, disabled, label, errored, className, value, id, ...props }: PhoneProps) => { // Use StateManager for internal state management const { state, setState } = useComponentState({ - dialing_code: value?.dialing_code || dialing_codes[0]?.value || '', - value: value?.value || '', + dialing_code: value && value.dialing_code || dialing_codes[0] && dialing_codes[0].value || '', + value: value && value.value || '', iso: '' }); @@ -73,19 +73,19 @@ module ProcessOut { // Set ISO code using libphonenumber if (!iso) { - const phoneUtil = (window as any).libphonenumber?.PhoneNumberUtil?.getInstance(); + const phoneUtil = (window as any).libphonenumber && (window as any).libphonenumber.PhoneNumberUtil && (window as any).libphonenumber.PhoneNumberUtil.getInstance(); if (phoneUtil) { try { const number = phoneUtil.parseAndKeepRawInput(getFullNumber(dialingCode, phoneNumber), ''); const regionCode = phoneUtil.getRegionCodeForNumber(number); - iso = regionCode || dialing_codes.find(item => item.value === dialingCode)?.region_code || ''; + iso = regionCode || dialing_codes.find(item => item.value === dialingCode) && dialing_codes.find(item => item.value === dialingCode).region_code || ''; } catch (error) { // Fallback to manual lookup if parsing fails - iso = dialing_codes.find(item => item.value === state.dialing_code)?.region_code || ''; + iso = dialing_codes.find(item => item.value === state.dialing_code) && dialing_codes.find(item => item.value === state.dialing_code).region_code || ''; } } else { // Fallback if libphonenumber not available - iso = dialing_codes.find(item => item.value === state.dialing_code)?.region_code || ''; + iso = dialing_codes.find(item => item.value === state.dialing_code) && dialing_codes.find(item => item.value === state.dialing_code).region_code || ''; } } diff --git a/src/apm/elements/qr.ts b/src/apm/elements/qr.ts index 9dec960c..3edbcf68 100644 --- a/src/apm/elements/qr.ts +++ b/src/apm/elements/qr.ts @@ -89,7 +89,7 @@ module ProcessOut { // Only enable download button if canvas is available downloadButtonRef.disabled = !state.canvas; downloadButtonRef.classList.remove('loading'); - downloadButtonRef.querySelector('.loader')?.remove(); + downloadButtonRef.querySelector('.loader') && downloadButtonRef.querySelector('.loader').remove(); } } @@ -191,12 +191,21 @@ module ProcessOut { // Clear skeleton and create QR code domElement.innerHTML = '' + let colorDark, colorLight; + if (ThemeImpl.mode === 'light') { + colorDark = ThemeImpl.instance.get('palette.light.text.default'); + colorLight = ThemeImpl.instance.get('palette.light.background'); + } else { + colorDark = ThemeImpl.instance.get('palette.dark.text.default'); + colorLight = ThemeImpl.instance.get('palette.dark.background'); + } + new window.globalThis.QRCode(domElement, { text, width: size, height: size, - colorDark: ThemeImpl.mode === 'light' ? ThemeImpl.instance.get('palette.light.text.default') : ThemeImpl.instance.get('palette.dark.text.default'), - colorLight: ThemeImpl.mode === 'light' ? ThemeImpl.instance.get('palette.light.background') : ThemeImpl.instance.get('palette.dark.background'), + colorDark: colorDark, + colorLight: colorLight, }) // Store reference to the canvas in state diff --git a/src/apm/elements/subheader.ts b/src/apm/elements/subheader.ts index 94c74544..4acbbf7f 100644 --- a/src/apm/elements/subheader.ts +++ b/src/apm/elements/subheader.ts @@ -8,8 +8,16 @@ module ProcessOut { export const SubHeader = (...args: HeaderArgs) => { const first = args[0] - const content: string = isProps(first) ? args[1] : first; - const props: SubHeaderTagProps = isProps(first) ? first : {} as SubHeaderTagProps + let content: string; + let props: SubHeaderTagProps; + + if (isProps(first)) { + content = args[1]; + props = first; + } else { + content = first; + props = {} as SubHeaderTagProps; + } const tag: SubHeaderTag = props.tag || 'h2'; delete props.tag diff --git a/src/apm/events/EventListener.ts b/src/apm/events/EventListener.ts index c8020b01..cd85d20a 100644 --- a/src/apm/events/EventListener.ts +++ b/src/apm/events/EventListener.ts @@ -23,7 +23,10 @@ module ProcessOut { private handlers: { [K in keyof M]?: EventHandler[] } = {}; on(key: K, handler: EventHandler) { - (this.handlers[key] ??= []).push(handler); + if (!this.handlers[key]) { + this.handlers[key] = []; + } + this.handlers[key].push(handler); } off(key: K, handler: EventHandler) { @@ -37,7 +40,7 @@ module ProcessOut { ) { const data = payload[0] as M[K]; // undefined for “no-payload” keys // Emit to specific event handlers - this.handlers[key]?.forEach(handler => (handler as any)(data)); + this.handlers[key] && this.handlers[key].forEach(handler => (handler as any)(data)); // Emit to '*' handlers with unified structure if (key !== '*' && this.handlers['*']) { diff --git a/src/apm/index.ts b/src/apm/index.ts index c6087eb3..42b481f0 100644 --- a/src/apm/index.ts +++ b/src/apm/index.ts @@ -13,7 +13,12 @@ module ProcessOut { export class APMImpl implements APM { constructor(poClient: ProcessOut, logger: TelemetryClient, container: Container, options: APMOptions) { - let containerEl = typeof container === 'string' ? document.querySelector(container) : container + let containerEl = container + + if (typeof containerEl === 'string') { + containerEl = document.querySelector(containerEl); + } + const { theme, ...data } = options if (theme) { diff --git a/src/apm/layouts/Main.ts b/src/apm/layouts/Main.ts index e534d7c9..170dbe94 100644 --- a/src/apm/layouts/Main.ts +++ b/src/apm/layouts/Main.ts @@ -8,33 +8,53 @@ module ProcessOut { } export function Main({ config, hideAmount, buttons, ...props }: MainProps, ...children: VNode[]) { + let header = null; + let amount = null; + let buttonsContainer = null; + + if (!hideAmount && config && config.invoice) { + amount = div({ className: 'amount' }, + `Pay ${formatCurrency(config.invoice.amount, config.invoice.currency)}` + ) + } + + if (config && config.payment_method) { + header = div({ className: 'header' }, + div({ className: 'logo' }, + picture({}, + source({ + media: '(prefers-color-scheme: dark)', + srcset: config.payment_method.logo.dark_url.raster + }), + img({ + src: config.payment_method.logo.light_url.raster, + alt: config.payment_method.display_name, + height: 34 + }) + ) + ), + amount + ) + } + + if (buttons) { + let content = buttons; + + if (!Array.isArray(content)) { + content = [content]; + } + + buttonsContainer = div({ className: 'buttons-container' }, + ...content + ) + } + return ( page(props, - config?.payment_method - ? div({ className: 'header' }, - div({ className: 'logo' }, - picture({}, - source({ - media: '(prefers-color-scheme: dark)', - srcset: config.payment_method.logo.dark_url.raster - }), - img({ - src: config.payment_method.logo.light_url.raster, - alt: config.payment_method.display_name, - height: 34 - }) - ) - ), - !hideAmount && config.invoice ? div({ className: 'amount' }, - `Pay ${formatCurrency(config.invoice.amount, config.invoice.currency)}` - ) : null - ) - : null, + header, div({ className: 'container'}, ...children, - buttons ? div({ className: 'buttons-container' }, - ...(Array.isArray(buttons) ? buttons : [buttons]) - ) : null + buttonsContainer ), ) ) diff --git a/src/apm/utils.ts b/src/apm/utils.ts index eebf56bf..eacf6eea 100644 --- a/src/apm/utils.ts +++ b/src/apm/utils.ts @@ -30,7 +30,12 @@ module ProcessOut { function dedent(strings: TemplateStringsArray, ...values: unknown[]): string { const raw = String.raw(strings, ...values); // untouched text const match = raw.match(/^[ \t]*(?=\S)/m); - const indent = match ? match[0].length : 0; // handle empty strings + let indent; + if (match) { + indent = match[0].length; + } else { + indent = 0; + } const pattern = new RegExp(`^[ \\t]{0,${indent}}`, 'gm'); return raw.replace(pattern, '').trim(); // strip & trim } @@ -170,7 +175,12 @@ module ProcessOut { if (err instanceof DOMException && err.name === 'NotAllowedError') { const styleEl = document.createElement('style'); styleEl.textContent = compatibleRules; - const host = isShadowRoot ? root : (root as Document).head; + let host; + if (isShadowRoot) { + host = root; + } else { + host = (root as Document).head; + } host.appendChild(styleEl); } else { throw err; @@ -189,7 +199,12 @@ module ProcessOut { const styleEl = (root as Document).createElement('style'); styleEl.textContent = compatibleRules; - const host = isShadowRoot ? root : (root as Document).head; + let host; + if (isShadowRoot) { + host = root; + } else { + host = (root as Document).head; + } host.appendChild(styleEl); } } @@ -201,7 +216,12 @@ module ProcessOut { return dedent`${strings .map((str, i) => { const expr = exprs[i]; - const value = typeof expr === 'function' ? expr.call(this) : expr ?? ''; + let value; + if (typeof expr === 'function') { + value = expr.call(this); + } else { + value = expr || ''; + } return str + value; }) .join('')}` as CSSText diff --git a/src/apm/views/Error.ts b/src/apm/views/Error.ts index 60018cf7..f8d00459 100644 --- a/src/apm/views/Error.ts +++ b/src/apm/views/Error.ts @@ -42,9 +42,13 @@ module ProcessOut { return page({ className: "error-page"}, h1({ className: 'error-title' }, this.props.title || 'Whoops! Something went wrong.'), p({ className: 'error-description' }, this.props.message || 'We apologize for the inconvenience.'), - !this.props.hideRefresh - ? Button({ className: 'error-refresh', onclick: this.onRefreshClick.bind(this) }, 'Refresh') - : null, + (() => { + if (!this.props.hideRefresh) { + return Button({ className: 'error-refresh', onclick: this.onRefreshClick.bind(this) }, 'Refresh'); + } else { + return null; + } + })(), ) } } diff --git a/src/apm/views/NextSteps.ts b/src/apm/views/NextSteps.ts index 533c280e..4c04fb30 100644 --- a/src/apm/views/NextSteps.ts +++ b/src/apm/views/NextSteps.ts @@ -12,8 +12,13 @@ module ProcessOut { const { div } = elements const setFormState = (elements: NextStepProps['elements'], config: NextStepProps['config']): FormState | null => { - const error = 'error' in config ? config.error : undefined; - const forms = elements?.filter(e => e.type === "form") ?? [] + let error; + if ('error' in config) { + error = config.error; + } else { + error = undefined; + } + const forms = elements && elements.filter(e => e.type === "form") || [] if (forms.length === 0) { return null @@ -23,7 +28,7 @@ module ProcessOut { touched: {}, values: {}, validation: {}, - errors: error?.invalid_fields?.reduce((acc, item) => { + errors: error && error.invalid_fields && error.invalid_fields.reduce((acc, item) => { acc[item.name] = item.message return acc; }, {}) || {} @@ -34,7 +39,7 @@ module ProcessOut { form.parameters.parameter_definitions.forEach(param => { // Check for prefilled data from initialData const initialData = ContextImpl.context.initialData; - const prefilledValue = initialData?.[param.key]; + const prefilledValue = initialData && initialData[param.key]; // If we have prefilled data, use it and exit early if (prefilledValue) { @@ -52,7 +57,7 @@ module ProcessOut { switch (param.type) { case 'single-select': - acc[param.key] = param.available_values.find(item => item.preselected)?.value || param.available_values[0].value + acc[param.key] = param.available_values.find(item => item.preselected) && param.available_values.find(item => item.preselected).value || param.available_values[0] && param.available_values[0].value || '' break; case 'phone': acc[param.key] = { @@ -76,9 +81,21 @@ module ProcessOut { ...acc, [param.key]: { email: param.type === "email", - required: param.required ?? false, - minLength: 'min_length' in param ? param.min_length : undefined, - maxLength: 'max_length' in param ? param.max_length : undefined, + required: param.required || false, + minLength: (() => { + if ('min_length' in param) { + return param.min_length; + } else { + return undefined; + } + })(), + maxLength: (() => { + if ('max_length' in param) { + return param.max_length; + } else { + return undefined; + } + })(), } } }, {}) @@ -114,7 +131,7 @@ module ProcessOut { } this.setState({ loading: true }); - ContextImpl.context.page.load(APIImpl.sendFormData(state.form?.values ?? {}), (err, state) => { + ContextImpl.context.page.load(APIImpl.sendFormData(state.form && state.form.values || {}), (err, state) => { if (err) { ContextImpl.context.events.emit('submit-error', { failure: { code: err.code, message: err.message } }) } else { @@ -124,15 +141,23 @@ module ProcessOut { } render() { - const hasErrors = this.state.form?.errors ? - Object.keys(this.state.form.errors).some(key => this.state.form.errors[key]) : - false; + let hasErrors = false; + + if (this.state.form && this.state.form.errors) { + hasErrors = Object.keys(this.state.form.errors).some(key => this.state.form.errors[key]) + } return Main({ config: this.props.config, buttons: [ Button({ onclick: this.handleSubmit.bind(this), disabled: hasErrors, loading: this.state.loading }, 'Continue'), - (ContextImpl.context.allowCancelation ? CancelButton({ config: this.props.config as APISuccessBase & Partial }) : null) + (() => { + if (ContextImpl.context.allowCancelation) { + return CancelButton({ config: this.props.config as APISuccessBase & Partial }); + } else { + return null; + } + })() ] }, ...renderElements( diff --git a/src/apm/views/Pending.ts b/src/apm/views/Pending.ts index 975342a5..c26cbd7f 100644 --- a/src/apm/views/Pending.ts +++ b/src/apm/views/Pending.ts @@ -84,7 +84,7 @@ module ProcessOut { private intervalId: number | null = null private get confirmed(): boolean { - if (!ContextImpl.context.confirmation.requiresAction || !this.props.elements?.length) { + if (!ContextImpl.context.confirmation.requiresAction || !this.props.elements || !this.props.elements.length) { return true } @@ -145,7 +145,12 @@ module ProcessOut { formatCountdown(seconds: number): string { const minutes = Math.floor(seconds / 60) const remainingSeconds = seconds % 60 - const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds.toString() + let formattedSeconds; + if (remainingSeconds < 10) { + formattedSeconds = `0${remainingSeconds}`; + } else { + formattedSeconds = remainingSeconds.toString(); + } return `${minutes}:${formattedSeconds}` } @@ -167,42 +172,88 @@ module ProcessOut { render() { const confirmed = this.confirmed + let step1Status, step1Title, step2Status, step2Description; + + if (!confirmed) { + step1Status = 'pending'; + step1Title = 'Waiting for payment'; + } else { + step1Status = 'completed'; + step1Title = 'Payment sent'; + } + + if (!confirmed) { + step2Status = 'idle'; + } else { + step2Status = 'pending'; + } + + if (confirmed) { + step2Description = `Please wait up to ${this.formatCountdown(this.state.countdown)} minutes`; + } else { + step2Description = undefined; + } + const steps: Array<{ status: 'completed' | 'pending' | 'idle', title: string, description?: string, elements?: APIElements }> = [ { - status: !confirmed ? 'pending' : 'completed', - title: !confirmed ? 'Waiting for payment' : 'Payment sent', + status: step1Status, + title: step1Title, }, { - status: !confirmed? 'idle' : 'pending', + status: step2Status, title: 'Waiting for confirmation', - description: confirmed ? `Please wait up to ${this.formatCountdown(this.state.countdown)} minutes` : undefined, - elements: this.props?.elements + description: step2Description, + elements: this.props && this.props.elements }, ] + let confirmButton, cancelButton; + + if (!confirmed) { + confirmButton = Button({ onclick: this.handleConfirmClick.bind(this) }, 'I have sent the payment'); + } else { + confirmButton = null; + } + + if (ContextImpl.context.confirmation.allowCancelation) { + cancelButton = CancelButton({ onClick: this.handleCancelClick.bind(this), config: this.props.config }); + } else { + cancelButton = null; + } + return Main({ config: this.props.config, className: "pending-page", buttons: [ - (!confirmed - ? Button({ onclick: this.handleConfirmClick.bind(this) }, 'I have sent the payment') - : null - ), - (ContextImpl.context.confirmation.allowCancelation - ? CancelButton({ onClick: this.handleCancelClick.bind(this), config: this.props.config }) - : null - ) + confirmButton, + cancelButton ] }, div({ className: "steps" }, - ...steps.map(step => div({ className: `step ${step.status}` }, - div({ className: "step-status" }, StatusTick({ state: step.status })), - div({ className: "step-content" }, - div({ className: "step-title" }, step.title), - step.description ? div({ className: "step-description" }, step.description) : null, - step.elements ? renderElements(step.elements) : null - ) - )) + ...steps.map(step => { + let descriptionElement, elementsElement; + + if (step.description) { + descriptionElement = div({ className: "step-description" }, step.description); + } else { + descriptionElement = null; + } + + if (step.elements) { + elementsElement = renderElements(step.elements); + } else { + elementsElement = null; + } + + return div({ className: `step ${step.status}` }, + div({ className: "step-status" }, StatusTick({ state: step.status })), + div({ className: "step-content" }, + div({ className: "step-title" }, step.title), + descriptionElement, + elementsElement + ) + ); + }) ) ) } diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts index 36677176..b83bb521 100644 --- a/src/apm/views/Redirect.ts +++ b/src/apm/views/Redirect.ts @@ -34,10 +34,13 @@ module ProcessOut { hideAmount: true, buttons: [ Button({ onclick: this.handleRedirectClick.bind(this) }, redirectLabel), - (ContextImpl.context.confirmation.allowCancelation - ? CancelButton({ config: this.props.config }) - : null - ) + (() => { + if (ContextImpl.context.confirmation.allowCancelation) { + return CancelButton({ config: this.props.config }); + } else { + return null; + } + })() ] }, div({ className: 'heading-container' }, diff --git a/src/apm/views/Success.ts b/src/apm/views/Success.ts index a4d1289e..78e319db 100644 --- a/src/apm/views/Success.ts +++ b/src/apm/views/Success.ts @@ -52,18 +52,23 @@ module ProcessOut { } ` - private timeout = ContextImpl.context.success.requiresAction ? ContextImpl.context.success.manualDismissDuration : ContextImpl.context.success.autoDismissDuration + private timeout; + + constructor(container: Element, shadow: ShadowRoot | Document, props: SuccessProps) { + super(container, shadow, props); + + if (ContextImpl.context.success.requiresAction) { + this.timeout = ContextImpl.context.success.manualDismissDuration; + } else { + this.timeout = ContextImpl.context.success.autoDismissDuration; + } + } handleDoneClick() { ContextImpl.context.events.emit('success', { trigger: 'user' }); } render() { - if (!this.props.config.invoice) { - ContextImpl.context.events.emit('success', { trigger: 'immediate' }); - return null - } - if (!this.timeoutSet && this.timeout > 0) { this.timeoutSet = true setTimeout(() => { @@ -75,8 +80,13 @@ module ProcessOut { config: this.props.config, className: "success-page", hideAmount: true, - buttons: ContextImpl.context.success.requiresAction - ? Button({ onclick: this.handleDoneClick.bind(this) }, 'Done') : null + buttons: (() => { + if (ContextImpl.context.success.requiresAction) { + return Button({ onclick: this.handleDoneClick.bind(this) }, 'Done'); + } else { + return null; + } + })() }, div({ className: 'success-message' }, div({ className: 'tick-container' }, diff --git a/src/apm/views/View.ts b/src/apm/views/View.ts index ce1c2bb3..4a91ae84 100644 --- a/src/apm/views/View.ts +++ b/src/apm/views/View.ts @@ -486,7 +486,12 @@ module ProcessOut { // Handle style objects: { width: '100px', height: '100px' } if (key === 'style' && typeof newValue === 'object' && newValue !== null) { - const oldStyle = (typeof oldValue === 'object' && oldValue !== null) ? oldValue : {}; + let oldStyle; + if (typeof oldValue === 'object' && oldValue !== null) { + oldStyle = oldValue; + } else { + oldStyle = {}; + } // Remove old style properties that are no longer present for (const styleKey in oldStyle) { @@ -824,7 +829,12 @@ module ProcessOut { } const key = this._getKey(newStartVNode); - const indexInOld = key != null ? oldKeyMap[key] : undefined; + let indexInOld; + if (key != null) { + indexInOld = oldKeyMap[key]; + } else { + indexInOld = undefined; + } if (indexInOld == null) { // New element - create and insert diff --git a/src/apm/views/utils/form.ts b/src/apm/views/utils/form.ts index 9973fbc7..8642032a 100644 --- a/src/apm/views/utils/form.ts +++ b/src/apm/views/utils/form.ts @@ -24,7 +24,12 @@ module ProcessOut { return } - const actualValue = isPlainObject(value) && 'value' in value ? value.value : value + let actualValue; + if (isPlainObject(value) && 'value' in value) { + actualValue = value.value; + } else { + actualValue = value; + } switch (true) { case validation.required && @@ -158,11 +163,18 @@ module ProcessOut { switch (field.type) { case "otp": { + let otpType; + if (field.subtype === "digits") { + otpType = "numeric"; + } else { + otpType = "text"; + } + input = OTP({ name: field.key, label: field.label, length: field.min_length, - type: field.subtype === "digits" ? "numeric" : "text", + type: otpType, disabled: state.loading, errored: !!error, value: value as string, @@ -188,7 +200,7 @@ module ProcessOut { input = Select({ name: field.key, label: field.label, - value: value as string || field.available_values.find(item => item.preselected)?.value || '', + value: value as string || field.available_values.find(item => item.preselected) && field.available_values.find(item => item.preselected).value || field.available_values[0] && field.available_values[0].value || '', options: field.available_values, errored: !!error, disabled: state.loading, @@ -235,7 +247,13 @@ module ProcessOut { } } - return div({ className: `field-container ${field.type}-field` }, input, error ? label({ htmlFor: labelHtmlFor, className: "error" }, error) : null) + let errorLabel = null; + + if (error) { + errorLabel = label({ htmlFor: labelHtmlFor, className: "error" }, error) + } + + return div({ className: `field-container ${field.type}-field` }, input, errorLabel) } // Grouping function for form fields diff --git a/src/apm/views/utils/render-elements.ts b/src/apm/views/utils/render-elements.ts index 925fd297..1aef56db 100644 --- a/src/apm/views/utils/render-elements.ts +++ b/src/apm/views/utils/render-elements.ts @@ -40,11 +40,19 @@ module ProcessOut { const nextItem = items[i + 1] const groupInfo = getGroup(item); - const groupType = groupInfo?.type; + const groupType = groupInfo && groupInfo.type; - const nextGroup = nextItem ? getGroup(nextItem) : null - const nextGroupType = nextGroup ? nextGroup.type : null + let nextGroup = null + let nextGroupType = null + if (nextItem) { + nextGroup = getGroup(nextItem) + } + + if (nextGroup) { + nextGroupType = nextGroup.type + } + const renderedElement = renderItem(item) if (!groupType) { @@ -60,7 +68,7 @@ module ProcessOut { continue } - if (!inGroup || currentGroupInfo?.type !== groupType) { + if (!inGroup || currentGroupInfo && currentGroupInfo.type !== groupType) { // Close any existing group if we're starting a different group type if (inGroup) { result.push(div({ className: containerClassName }, ...currentGroup)) @@ -123,10 +131,10 @@ module ProcessOut { (element) => renderElement( { ...element, - setState: options?.setState || (() => {}), - handleSubmit: options?.handleSubmit || (() => {}), + setState: options && options.setState || (() => {}), + handleSubmit: options && options.handleSubmit || (() => {}), }, - options?.state || { loading: false } + options && options.state || { loading: false } ), ) }