import { bubble } from "@web3r/flowerkit/evt"; import { getExternalScript } from "@web3r/flowerkit/net"; import { getWindow, getDocument } from "ssr-window"; import { constants } from "../../constants"; import { getCfg } from "../../utils/getCfg"; import { Collection } from "../collection"; import type { TCollectionElement, TCollectionItemDefaultCfg } from "../collection"; import { Dispatcher } from "../dispatcher"; /** * DOM-элемент экземпляра captcha в коллекции. */ export type TCaptchaElement = TCollectionElement; /** * Состояние жизненного цикла виджета captcha. */ export type TCaptchaState = { render: boolean; ready: boolean; expired: boolean; error: boolean; verify: boolean; }; /** * Класс для управления Google Captcha. */ class Captcha { stateClasses = { rendered: "isRendered", expired: "isExpired", error: "isError", verify: "isVerify", }; #defaultCfg = { "sitekey": getWindow()?.App?.googleCaptchaKey ?? "0", "theme": "dark", "callback": (response: string) => this.#handleVerify(response), "size": "normal", "error-callback": this.#handleError, "expired-callback": this.#handleExpired, }; instance: TCaptchaElement; state: TCaptchaState; cfg: TCollectionItemDefaultCfg; constructor(instance: TCaptchaElement, _params: TCollectionItemDefaultCfg = {}) { this.instance = instance; this.state = { render: false, ready: false, expired: false, error: false, verify: false, }; if (CaptchaCollection.state.isAPIReady) { if (!this.state.render) { this.cfg = getCfg(this.instance as HTMLElement, CaptchaCollection.selector, this.#defaultCfg); this.bindEvents(); } else if (getWindow()?.App?.isDebug) { console.debug("[Captcha] Object are not defined or already rendered: ", instance); } } else { CaptchaCollection.loadAPI(); } } #handleError() { this.instance.classList.add(this.stateClasses.error); this.instance.classList.remove(this.stateClasses.verify, constants.stateClasses.valid); this.state = { ...this.state, error: true, ready: false, verify: false, }; bubble(getDocument(), CaptchaCollection.bubbles.error, this.instance); } #handleExpired() { this.instance.classList.add(this.stateClasses.expired); this.instance.classList.remove(this.stateClasses.verify, constants.stateClasses.valid); this.state = { ...this.state, ready: false, verify: false, expired: true, }; bubble(getDocument(), CaptchaCollection.bubbles.expire, this.instance); } #handleVerify(response: string) { this.instance.classList.add(this.stateClasses.verify, constants.stateClasses.valid); this.instance.classList.remove(constants.stateClasses.invalid); this.state.verify = true; bubble(getDocument(), CaptchaCollection.bubbles.verify, { el: this.instance, response, states: this.state }); } render() { getWindow()?.grecaptcha.render(this.instance, this.cfg); this.instance.classList.add(this.stateClasses.rendered); this.state.render = true; getWindow()?.grecaptcha.ready(() => { bubble(getDocument(), CaptchaCollection.bubbles.render, { el: this.instance, states: this.state }); }); } getResponse() { return getWindow()?.grecaptcha.getResponse(this.instance); } reset() { try { getWindow()?.grecaptcha.reset(this.instance); } catch (e: unknown) { console.error(e); } this.state.verify = false; this.instance.classList.remove(this.stateClasses.verify); bubble(getDocument(), CaptchaCollection.bubbles.reset, this.instance); } #handleReady() { this.state.ready = true; if (this.instance.children.length) { this.reset(); } else { this.render(); } } #handleValidation(_event?: Event) { // FormValidation.setValidClassToEl(this.instance, detail.isValid) } bindEvents() { getWindow()?.grecaptcha.ready(() => this.#handleReady()); [ constants.formBubbles.inputValid, constants.formBubbles.inputInvalid ].forEach((eventName: string) => this.instance.addEventListener(eventName, (e: Event) => this.#handleValidation(e))); } } /** * Класс, управляющий загрузкой API * @private */ class CaptchaApiManager { /** * URL подключения внешнего скрипта Google reCAPTCHA. * @type {string} */ static apiUrl = `https://www.google.com/recaptcha/api.js?render=explicit&onload=bubbleCaptchaAPIReady`; /** * Singleton-экземпляр менеджера загрузки API. * @type {CaptchaApiManager|null} */ static instance: CaptchaApiManager | null = null; constructor() { if (CaptchaApiManager.instance) { return CaptchaApiManager.instance; } getWindow().bubbleCaptchaAPIReady = () => CaptchaApiManager.handleScriptReady(); CaptchaApiManager.instance = this; } static handleScriptReady() { CaptchaCollection.state.isAPIReady = true; bubble(getDocument(), CaptchaCollection.bubbles.ready); if (getWindow()?.App?.isDebug) { console.debug("[Captcha] API are ready"); } } static async load(): Promise { if (CaptchaCollection.state.isScriptLoaded) { return CaptchaApiManager.handleScriptReady(); } else if (!CaptchaCollection.state.isScriptLoading) { CaptchaCollection.state.isScriptLoading = true; return await getExternalScript({ isDefer: true, src: CaptchaApiManager.apiUrl, }); } } static handleScriptError(script: unknown) { CaptchaCollection.state = { ...CaptchaCollection.state, isScriptLoaded: false, isScriptLoading: false, isAPIReady: false, }; console.error("[Captcha] API are not loaded: ", script); } static handleScriptLoad(_script?: unknown) { CaptchaCollection.state = { ...CaptchaCollection.state, isScriptLoaded: true, isScriptLoading: false, }; } } /** * Плагин, представляющий коллекцию Google Captcha. * Автоматически загружает API v2. Является коллекцией. * @module CaptchaCollection * @memberof Collection * @example * //
*/ class CaptchaCollection extends Collection { /** * CSS-селектор элементов captcha для автоматической инициализации коллекции. * @type {string} */ static selector = "[data-js-google-captcha]"; /** * Набор всплывающих событий жизненного цикла captcha. * @type {{mount: string, unmount: string, ready: string, render: string, reset: string, verify: string, expire: string, error: string}} */ static bubbles = { mount: "collection::mount", unmount: "collection::unmount", ready: "googleCaptchaReady", render: "googleCaptchaRender", reset: "googleCaptchaReset", verify: "googleCaptchaVerify", expire: "googleCaptchaExpire", error: "googleCaptchaError", }; /** * Глобальное состояние загрузки скрипта и готовности reCAPTCHA API. * @type {{isScriptLoaded: boolean, isScriptLoading: boolean, isAPIReady: boolean}} */ static state = { isScriptLoaded: false, isScriptLoading: false, isAPIReady: false, }; constructor() { new CaptchaApiManager(); super(CaptchaCollection.selector, Captcha); } getByDOMElement(DOMElement: Element): Captcha | null { return this.collection.find((item) => item.instance === DOMElement && CaptchaCollection.state.isAPIReady) || null; } static loadAPI() { if (!CaptchaCollection.state.isScriptLoading) { CaptchaApiManager.load().then((script: unknown) => { return CaptchaApiManager.handleScriptLoad(script); }, (script: unknown) => { CaptchaApiManager.handleScriptError(script); }); } } init(context: Document | HTMLElement = getDocument()) { const items = context.querySelectorAll(CaptchaCollection.selector); if (items.length) { if (CaptchaCollection.state.isAPIReady) { items.forEach((el) => { const instance = new Captcha(el as TCaptchaElement); this.addToCollection(instance); }); } else { CaptchaCollection.loadAPI(); } } } bindEvents() { getDocument().addEventListener(CaptchaCollection.bubbles.ready, () => this.init()); Dispatcher.initiator = { selector: CaptchaCollection.selector, initiator: (context: Document | HTMLElement) => this.init(context), getCollection: () => this.collection, }; } } export { Captcha, CaptchaCollection, };