@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
JavaScript
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 };