UNPKG

@adyen/kyc-components

Version:

This guide assumes that you have already an account with Adyen. A legalEntity needs to be created, and you need to have a `legalEntityId` to instatiate a Component.

505 lines (504 loc) 19 kB
try { let e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {}, n = new e.Error().stack; n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "30139b26-2983-4a91-b366-79ef65f8dc76", e._sentryDebugIdIdentifier = "sentry-dbid-30139b26-2983-4a91-b366-79ef65f8dc76"); } catch (e) {} import { o as createLogger, r as useTranslation } from "./translation-BYvhW5zA.js"; import { t as Button } from "./Button-i8I2dHP8.js"; import { i as useAnalyticsContext } from "./Modal-BLP2aF-u.js"; import { r as LoaderWrapper } from "./utils-C1buyvRw.js"; import { t as Confirm } from "./Confirm-CA-gL38n.js"; import { t as formDebugInfo } from "./debugStore-Dwn1FXrh.js"; import { r as FormRouterContext } from "./ErrorPanel-DGlzO81g.js"; import { t as trackNavigation } from "./trackNavigation-DR70qR8T.js"; import { n as summaryStep, t as Summary } from "./Summary-HxzWpI7W.js"; import { t as MaybeModal } from "./MaybeModal-DWfb_GRW.js"; import { t as ProgressBar } from "./ProgressBar-DWFxLMUA.js"; import { useCallback, useContext, useMemo, useRef, useState } from "preact/hooks"; import cx from "classnames"; import { jsx, jsxs } from "preact/jsx-runtime"; import { createContext } from "preact"; //#region src/context/FormContext/FormContext.ts var FormContext = createContext(null); //#endregion //#region src/context/FormContext/FormContextProvider.tsx function FormContextProvider({ children, form }) { const contextValue = useMemo(() => ({ form }), [form]); return /* @__PURE__ */ jsx(FormContext.Provider, { value: contextValue, children }); } //#endregion //#region src/hooks/usePageLandedEvent.ts /** * Hook that automatically sends a "Landed on page" analytics event on when active form changes. * Used on form component level (i.e. Basic information, Additional details etc.) to explicitly track page visits. * This centralizes the common analytics pattern used across form components. */ var usePageLandedEvent = (formName) => { const userEvents = useAnalyticsContext(); const oldFormNameRef = useRef(void 0); if (formName && oldFormNameRef.current !== formName) { userEvents.updateSharedEventProperties({ page: formName }); userEvents.addPageEvent("Landed on page", { actionType: "navigate" }); oldFormNameRef.current = formName; } }; //#endregion //#region src/utils/scrollToInvalidField.ts var INVALID_FIELD_SELECTOR = ".adyen-kyc-field--error, .adyen-kyc-label__text--error"; var SCROLLABLE_OVERFLOW_VALUES = new Set([ "auto", "scroll", "overlay" ]); var isWindow = (container) => container === window; var getInvalidFieldTarget = (element) => element.closest(".adyen-kyc-field") ?? element; var isCustomElement = (element) => element.tagName.includes("-"); var queryInvalidField = (root) => { const invalidField = root.querySelector(INVALID_FIELD_SELECTOR); if (invalidField) return getInvalidFieldTarget(invalidField); for (const element of Array.from(root.querySelectorAll("*")).filter(isCustomElement)) { if (!element.shadowRoot) continue; const shadowInvalidField = queryInvalidField(element.shadowRoot); if (shadowInvalidField) return shadowInvalidField; } return null; }; var isElementVisibleInContainer = (element, container) => { const elementRect = element.getBoundingClientRect(); if (isWindow(container)) return elementRect.top >= 0 && elementRect.bottom <= window.innerHeight; const containerRect = container.getBoundingClientRect(); return elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom; }; var findScrollContainer = (element) => { let parent = element.parentElement; while (parent) { const style = window.getComputedStyle(parent); const canScroll = parent.scrollHeight > parent.clientHeight; const hasScrollableOverflow = SCROLLABLE_OVERFLOW_VALUES.has(style.overflowY); if (canScroll && hasScrollableOverflow) return parent; parent = parent.parentElement; } return window; }; var scrollElementIntoContainer = (element, container) => { if (isWindow(container)) { element.scrollIntoView({ behavior: "smooth", block: "center" }); return; } const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const offset = elementRect.top - containerRect.top - containerRect.height / 3; container.scrollBy({ top: offset, behavior: "smooth" }); }; var scrollToInvalidField = () => { const invalidField = queryInvalidField(document); if (!invalidField) return; const container = findScrollContainer(invalidField); if (isElementVisibleInContainer(invalidField, container)) return; scrollElementIntoContainer(invalidField, container); }; //#endregion //#region src/hooks/useFormComposer.ts var logger$1 = createLogger(); /** * The step we open to for editing an existing entity should be the summary if there are verification errors * Otherwise (including for a new entity), just show the first step */ var getOpeningStep = (forms, remediationActions) => { if (remediationActions && Object.keys(remediationActions).length > 0) { const allRemediationActions = Object.values(remediationActions).flat().filter((rem) => rem.forms?.length > 0); if (allRemediationActions.length === 1 && allRemediationActions[0]?.forms.length === 1) { const form = forms.find((form) => form?.formId === allRemediationActions[0]?.forms[0]); if (form) return form; } return forms[forms.length - 1]; } return forms[0]; }; var useFormComposer = ({ problems, navigationTrackingParams, forms, externalBackClick, onSubmit, triggerValidation }) => { const userEvents = useAnalyticsContext(); const [shouldValidate, setShouldValidate] = useState(false); const [activeForm, setActiveForm] = useState(forms[0]); const isFormSummaryStep = (form) => form.formId === summaryStep.formId; const getFormIndex = (formId) => forms.findIndex((form) => form.formId === formId); const currentStep = getFormIndex(activeForm.formId); const totalSteps = forms.length; const isFinalStep = currentStep === totalSteps - 1; const isFirstStep = currentStep === 0; const firstStepId = forms[0]?.formId; usePageLandedEvent(activeForm.formName); const [oldForms, setOldForms] = useState(forms); if (oldForms !== forms) { setActiveForm((activeForm) => forms.find(({ formId }) => formId === activeForm.formId) ?? activeForm); setOldForms(forms); } /** * Opening step can be calculated once since the problems are already known on drop-in load */ const [oldFirstStepId, setOldFirstStepId] = useState(void 0); if (oldFirstStepId !== firstStepId) { /** TODO: need a better solution * If firstStepId changes it means form configurations are being setup, * not able to put forms as the dependency here as it also updates whenever validity changes * should separate out forms into forms and validity ***/ setActiveForm(getOpeningStep(forms, problems?.remediationActions)); setOldFirstStepId(firstStepId); } const gotoFormByFormIndex = (nextFormIndex, currentForms) => { setActiveForm(currentForms?.[nextFormIndex] ?? forms[nextFormIndex]); }; /** * We often run into a situation where we want to navigate to a specific form immediately after * rearranging the forms in the flow. * * The issue here is that the callback, (e.g. {@link IdVerificationMethod.handleVerifyByInvite}) * has a reference only to the current forms, not what the forms will be post-rearrangement. * Hence, it is impossible to call {@link gotoFormByFormIndex} because we have no idea what the * eventual index of the form will be. * * The solution: set a desired form ID, then resolve the navigation as part of the render * once {@link forms} contains it. The subsequent {@link gotoFormByFormIndex} will immediately * cancel the current render and trigger a new one. */ const [desiredFormId, setDesiredFormId] = useState(); if (desiredFormId) { const targetFormIndex = forms.findIndex((form) => form.formId === desiredFormId); if (targetFormIndex === -1) logger$1.warn(`Desired form ${desiredFormId} not present. \nCurrent forms: ${forms.map((f) => f.formId).join(", ")}`); else { setDesiredFormId(void 0); gotoFormByFormIndex(targetFormIndex); } } const gotoFormByFormId = (formId) => { setDesiredFormId(formId); }; const trackSubmitClick = () => { trackNavigation({ userEvents, actionType: "submit", label: "submit", additionalTrackingParams: navigationTrackingParams }); }; const handleNextClick = async (updatedForms) => { const currentForms = updatedForms ?? forms; if (isFormSummaryStep(activeForm)) { trackSubmitClick(); onSubmit(); return; } /** * Returns validation result of async validators if any */ const isAsyncValidationValid = await triggerValidation?.(activeForm.formId) ?? true; /** * Checks if both static validations and asyncValidations pass */ if (!activeForm.isValid || !isAsyncValidationValid) { /** * shouldValidate is not required with useMultiForm */ setShouldValidate(true); requestAnimationFrame(scrollToInvalidField); trackNavigation({ userEvents, actionType: "next", label: "next", returnValue: "validation error", additionalTrackingParams: navigationTrackingParams }); return; } if (isFinalStep) { trackSubmitClick(); onSubmit(); return; } setShouldValidate(false); const toFormIndex = currentStep + 1; gotoFormByFormIndex(toFormIndex, currentForms); trackNavigation({ userEvents, actionType: "next", toForm: currentForms[toFormIndex]?.formName, label: "next", returnValue: "success", additionalTrackingParams: navigationTrackingParams }); }; const handleBackClick = () => { if (currentStep > 0) { const toForm = forms[currentStep - 1]; setActiveForm(toForm); trackNavigation({ userEvents, actionType: "back", toForm: toForm?.formName, label: "back", additionalTrackingParams: navigationTrackingParams }); } }; return { handleBackClick: isFirstStep ? externalBackClick : handleBackClick, handleNextClick, gotoFormByFormIndex, gotoFormByFormId, activeForm, shouldValidate, setShouldValidate, steps: { current: currentStep, total: totalSteps } }; }; //#endregion //#region src/context/FormRouterContext/FormRouterContextProvider.tsx var logger = createLogger(); function FormRouterContextProvider({ children, forms, setFormIndex, goToFormByFormId }) { const fieldActionsRef = useRef({}); const registerFieldAction = useCallback((fieldName, action) => { fieldActionsRef.current[fieldName] = action; }, []); const unregisterFieldAction = useCallback((fieldName) => { delete fieldActionsRef.current[fieldName]; }, []); const getFieldAction = useCallback((fieldName) => { return fieldActionsRef.current[fieldName]; }, []); const contextValue = useMemo(() => ({ setFormIndex, goToFormByFormId, goToFormByFieldName: (fieldName) => { if (fieldName === "idDocument") fieldName = "idVerificationMethod"; const formIndex = forms.findIndex((form) => form?.fields?.some((field) => field === fieldName)); if (formIndex > -1) setFormIndex(formIndex); else logger.error("No form was found to have that field so form navigation failed."); }, registerFieldAction, unregisterFieldAction, getFieldAction }), [ setFormIndex, goToFormByFormId, registerFieldAction, unregisterFieldAction, getFieldAction, forms ]); return /* @__PURE__ */ jsx(FormRouterContext.Provider, { value: contextValue, children }); } var FormFlow_module_default = { "form-flow": "_form-flow_lhddu_1", formFlow: "_form-flow_lhddu_1", "form-flow-content": "_form-flow-content_lhddu_9", formFlowContent: "_form-flow-content_lhddu_9", "form-flow-footer": "_form-flow-footer_lhddu_30", formFlowFooter: "_form-flow-footer_lhddu_30", "form-flow-footer-border": "_form-flow-footer-border_lhddu_45", formFlowFooterBorder: "_form-flow-footer-border_lhddu_45", "form-flow-actions": "_form-flow-actions_lhddu_49", formFlowActions: "_form-flow-actions_lhddu_49", "form-flow-primary-actions": "_form-flow-primary-actions_lhddu_56", formFlowPrimaryActions: "_form-flow-primary-actions_lhddu_56" }; //#endregion //#region src/components/Shared/FormFlow/FormFlow.tsx var FormFlow = ({ asModal = false, children, currentStep = 0, disableBackButton, disableNextButton, nextButtonLoading = false, forms, activeForm, gotoFormByFormIndex, gotoFormByFormId, handleBackClick, handleCancelClick, handleNextClick, loadingStatus, summary, summaryComponent, totalSteps = 1 }) => { const contentRef = useRef(null); const userEvents = useAnalyticsContext(); const { t } = useTranslation("common"); const [isConfirmPresented, setIsConfirmPresented] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const isActionDisabled = isSubmitting || isConfirmPresented || loadingStatus === "loading"; const hasBackButton = !!handleBackClick; const hasCancelButton = !!handleCancelClick; const isFirstStep = currentStep === 0; const isLastStep = currentStep === totalSteps - 1; const isSingleStepForm = totalSteps < 2; const progress = Math.floor(currentStep / (totalSteps - 1) * 100); const scrollToTop = () => { if (contentRef.current) contentRef.current.scroll({ top: 0, behavior: "smooth" }); }; const handleSubmit = () => { setIsSubmitting(true); Promise.resolve(handleNextClick()).then(() => { setIsSubmitting(false); scrollToTop(); }); }; const handleNext = () => { scrollToTop(); handleNextClick(); }; const handleBack = () => { scrollToTop(); handleBackClick?.(); }; const handleDismiss = () => { if (!hasCancelButton) return; setIsConfirmPresented(true); }; const handleCancelDismiss = () => { userEvents.addPageEvent("Closed modal", { actionType: "close", label: "cancel" }); setIsConfirmPresented(false); }; const handleConfirmDismiss = () => { userEvents.addPageEvent("Clicked button", { actionType: "navigate", label: "leave" }); setIsConfirmPresented(false); handleCancelClick?.(); }; const footerClassName = cx(FormFlow_module_default.formFlowFooter, { [FormFlow_module_default.formFlowFooterBorder]: isSingleStepForm }); return /* @__PURE__ */ jsxs(MaybeModal, { inline: !asModal, onClose: handleDismiss, size: "large", showCloseButton: hasCancelButton, children: [/* @__PURE__ */ jsxs("div", { ref: contentRef, className: FormFlow_module_default.formFlow, "data-form-valid": activeForm.isValid, "data-testid": "form-flow", children: [/* @__PURE__ */ jsx("div", { className: FormFlow_module_default.formFlowContent, children: /* @__PURE__ */ jsx(LoaderWrapper, { status: loadingStatus, formOpacityWhenLoading: 0, loaderSize: "large", children: /* @__PURE__ */ jsx(FormRouterContextProvider, { forms, setFormIndex: gotoFormByFormIndex, goToFormByFormId: gotoFormByFormId, children: isLastStep ? summaryComponent ? summaryComponent : summary !== void 0 ? /* @__PURE__ */ jsx(Summary, { ...summary, forms, gotoForm: gotoFormByFormIndex }) : children : children }) }) }), /* @__PURE__ */ jsxs("div", { className: footerClassName, children: [!isSingleStepForm && /* @__PURE__ */ jsx(ProgressBar, { ariaLabel: t(($) => $["progress"]), progress }), /* @__PURE__ */ jsxs("div", { className: FormFlow_module_default.formFlowActions, children: [ isFirstStep && hasCancelButton && /* @__PURE__ */ jsx(Button, { disabled: isActionDisabled || disableBackButton, onClick: handleDismiss, variant: "secondary", children: t(($) => $["cancel"]) }), !isFirstStep && hasBackButton && /* @__PURE__ */ jsx(Button, { disabled: isActionDisabled || disableBackButton, icon: "chevron-left", onClick: handleBack, variant: "secondary", children: t(($) => $["back"]) }), /* @__PURE__ */ jsx("div", { className: FormFlow_module_default.formFlowPrimaryActions, children: isLastStep ? /* @__PURE__ */ jsx(Button, { onClick: handleSubmit, loading: isSubmitting, children: t(($) => $["submit"]) }) : /* @__PURE__ */ jsx(Button, { onClick: handleNext, disabled: isActionDisabled || disableNextButton, loading: nextButtonLoading, children: t(($) => $["continue"]) }) }) ] })] })] }), isConfirmPresented && /* @__PURE__ */ jsx(Confirm, { confirmText: t(($) => $["leave"]), description: t(($) => $["unsavedChangesDescription"]), onCancel: handleCancelDismiss, onConfirm: handleConfirmDismiss, title: t(($) => $["unsavedChanges"]) })] }); }; //#endregion //#region src/utils/dropinUtils.ts /** * @@description Based requiredfields and optionalFields determine what forms need to be shown and add a summaryStep in the dropin * * @param forms * @param requiredFields * @param optionalFields * @param skipSummary */ var getRequiredForms = (forms, requiredFields, optionalFields, skipSummary) => { const reasons = []; const requiredForms = Object.values(forms).filter(({ formId }) => { if (requiredFields || optionalFields) { const hasRequired = Boolean(requiredFields?.[formId]?.length); const hasOptional = Boolean(optionalFields?.[formId]?.length); const isIncluded = hasRequired || hasOptional; const inclusionReasons = []; if (hasRequired) inclusionReasons.push("has required fields"); if (hasOptional) inclusionReasons.push("has optional fields"); if (isIncluded) reasons.push(`Form '${formId}': INCLUDED (${inclusionReasons.join(" & ")})`); else reasons.push(`Form '${formId}': EXCLUDED (no required or optional fields)`); return isIncluded; } reasons.push(`Form '${formId}': INCLUDED (no required/optional fields check)`); return true; }); formDebugInfo.value = { ...formDebugInfo.value, __FORMS__: { status: "INFO", reasons } }; if (skipSummary) return requiredForms; return [...requiredForms, summaryStep]; }; /** * @description Based on validity of fields from formValidity and existence of validationErrors/Verification errors from backend determine whether the forms needs to be shown as valid or not * * @param forms * @param formValidity * @param problems */ var addValidityToForms = (forms, formValidity, problems) => forms.map(({ formId, formName, fields }) => ({ formId, formName, fields, isValid: formValidity?.[formId] ?? false, hasServerValidationErrors: Boolean(problems?.validationErrors?.[formId]) })); //#endregion //#region src/context/FormContext/useFormContext.ts function useFormContext() { return useContext(FormContext); } //#endregion export { FormFlow_module_default as a, FormFlow as i, addValidityToForms as n, useFormComposer as o, getRequiredForms as r, FormContextProvider as s, useFormContext as t };