import * as React from 'react'; import { useState, useEffect, useMemo, useCallback, useLayoutEffect, useRef, memo, StrictMode, FC, } from 'react'; import { createRoot, Root } from 'react-dom/client'; import Api from './services/api'; import Widget from './components/Widget'; import { IData, ISettings, Sources, Config, IDebug, MinContentEvent, EventMapKeys, EventMap, AdalongWidgetInstance, } from './types'; import { Debug } from './debug'; import { Helpers } from './services/helpers'; import { DEFAULT_CONFIG, initial, StateSetter, StoreProvider, StoreState, } from './services/store'; import createWidgetEvents from './services/events'; import KeepAlive from './services/keepAlive'; import { AnalyticsConsent } from './services/analyticsConsent'; declare const __WIDGET_VERSION__: string; interface AdalongWidgetProps { token?: string; config?: Config; } // Separate interface for widget root props interface WidgetRootProps { token: string; config?: Config; onReady: (widget: ReturnType) => void; } // Wrapper component for hooks compliance, memory and state management const WidgetRoot: FC = memo(({ token, config, onReady }) => { const widget = useAdalongWidget({ token, config }); // Use layout effect to ensure synchronous execution useLayoutEffect(() => { onReady(widget); }, [widget, onReady]); return null; }); const initializeAdalongWidget = ({ token, config, }: { token: string; config?: Config; }): Promise => new Promise((resolve) => { Api.initialize(config); if (typeof config?.hasAnalyticsConsent === 'boolean') { AnalyticsConsent.set(config.hasAnalyticsConsent); } const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); const handleReady = (widget: ReturnType) => { resolve(widget); // Ensure cleanup happens after resolution queueMicrotask(() => { root.unmount(); container.remove(); }); }; root.render( , ); }); const useAdalongWidget = ({ token, config: initialConfig }: AdalongWidgetProps) => { const [loaded, setLoaded] = useState(false); const [layout, setLayout] = useState(); const [destroyed, setDestroyed] = useState(false); // Keep reference of last used element selector and settings for recovery const lastElementRef = useRef(null); const lastSettingsRef = useRef | undefined>(undefined); const keepAliveRef = useRef(null); const id = useMemo(() => initialConfig?.id || Helpers.randomId(), [initialConfig?.id]); const startDate = useMemo(() => new Date(), []); const events = useMemo(() => createWidgetEvents(), []); const config = useMemo( () => ({ ...DEFAULT_CONFIG, ...initialConfig, }), [initialConfig], ); const portalId = useMemo(() => `adalong-postViewer-${id}`, [id]); const portalRef = useRef(null); // Keep a single React root per target element to avoid duplicate roots on re-load. const reactRootRef = useRef<{ element: HTMLElement; root: Root } | null>(null); const cleanupObserverRef = useRef(null); const widgetVersion = 7; const storeStateRef = useRef<{ getState(): StoreState; stateSetter: StateSetter }>({ getState: () => initial, stateSetter: () => {}, }); const triggerEvent = useCallback( (eventName: K, event: EventMap[K], widgetId?: string) => { events.triggerEvent(eventName, event, widgetId); }, [events], ); const getSlideState = useCallback( () => ({ canSlideLeft: storeStateRef.current.getState().canSlideLeft, canSlideRight: storeStateRef.current.getState().canSlideRight, }), [], ); const changePost = useCallback((dir: 'left' | 'right') => { dispatchEvent(new CustomEvent('adalongWidget_changePost', { detail: dir })); }, []); const setSlider = useCallback((dir: 'left' | 'right') => { dispatchEvent(new CustomEvent('adalongWidget_slide', { detail: dir })); }, []); const setDebug = useCallback((debug: IDebug) => { if (!debug) return; Debug.try(() => { const { level } = debug; if (level) { Debug.setLevel(level); } }).catch(console.error); }, []); const updateHasAnalyticsConsent = useCallback((granted: boolean) => { AnalyticsConsent.set(granted); }, []); const loadWidgetContent = useCallback( async ( widgetToken: string, sources?: Sources, settings?: Partial, ): Promise => { if (!widgetToken) { throw new Error('No settings or token provided'); } const defaultSettings: ISettings & { id: string } = await Api.getSettings( widgetToken, widgetVersion, settings?.localization, ); const { id: settingsId, ...restDefaultSettings } = defaultSettings; const finalSettings: ISettings = { ...restDefaultSettings, ...settings, }; const defaultProductList = finalSettings.default_sources?.product_list; const dataProductsMissing = Array.isArray(defaultProductList) && defaultProductList.length === 0 && (!sources?.productIds || sources.productIds.length === 0); if (dataProductsMissing) { console.warn('Adalong Widget: data-products is missing'); return null; } const content = await Api.getContent(widgetToken, widgetVersion, sources, finalSettings); if (!content) { throw new Error('No content returned'); } content.medias = content.medias.map((media, i) => ({ ...media, postIndex: i })); const minContent: MinContentEvent = { required: finalSettings.min_media, current: content.medias.length, }; if (minContent.required && minContent.current < minContent.required) { if (storeStateRef.current.getState().hasSubscribed?.('minContentNotReached')) { storeStateRef.current .getState() .triggerEvent('minContentNotReached', minContent, settingsId); } return null; } storeStateRef.current.getState().triggerEvent('minContentReached', minContent, settingsId); return { settings: finalSettings, content, id: settingsId }; }, [widgetVersion], ); const load = useCallback( async (element: string | HTMLElement, settings?: Partial) => { if (!token || destroyed) { return; } // Store the element for potential recovery if (typeof element === 'string') { lastElementRef.current = element; } else if (element.id) { lastElementRef.current = `#${element.id}`; } else { lastElementRef.current = null; } lastSettingsRef.current = settings; await Debug.try(async () => { const rootElement = typeof element === 'string' ? document.querySelector(element) : element; if (!rootElement) { throw new Error('Root element not found'); } const parseDataset = (data: string | undefined): string[] | undefined => { if (!data) return undefined; return Helpers.isValidJson(data) ? (JSON.parse(data) as string[]) : data.split(','); }; const sources: Sources = { productIds: parseDataset(rootElement.dataset.products), collectionIds: parseDataset(rootElement.dataset.collections), }; const data = await loadWidgetContent(token, sources, settings); if (!data) return; setLayout(data.settings.type); // Clean up any existing keep-alive before creating a new one if (keepAliveRef.current) { keepAliveRef.current.stop(); } // Re-use the React root if load() is called again on the same element. if (reactRootRef.current && reactRootRef.current.element !== rootElement) { try { reactRootRef.current.root.unmount(); } catch (e) { Debug.log('Failed to unmount previous root', e); } reactRootRef.current = null; } if (!reactRootRef.current) { reactRootRef.current = { element: rootElement, root: createRoot(rootElement) }; } reactRootRef.current.root.render( StoreState, setter: StateSetter) => { storeStateRef.current = { getState, stateSetter: setter }; }, data, root: rootElement, forceMobile: rootElement.getAttribute('data-forcemobile') === 'true', isMobile: Helpers.isMobileDevice(), triggerEvent, config, }} > , ); setLoaded(true); // Set up keep-alive monitoring for auto-recovery. // NOTE: KeepAlive is intentionally conservative — see KeepAlive class for details. // We only enable it when an explicit selector is available. const elementSelector = typeof element === 'string' ? element : (element.id ? `#${element.id}` : null); if (elementSelector) { keepAliveRef.current = new KeepAlive({ targetSelector: elementSelector, onDisappear: () => { Debug.log('Widget disappeared, attempting to recover'); if (lastElementRef.current) { load(lastElementRef.current, lastSettingsRef.current); } }, maxAttempts: 3, checkInterval: 2000, }); keepAliveRef.current.start(); } // Setup mutation observer for cleanup. Scope it to the parent (or html) instead of // the entire document.body subtree to avoid firing on every unrelated DOM change. if (cleanupObserverRef.current) { cleanupObserverRef.current.disconnect(); } const cleanupTarget = rootElement.parentNode || document.documentElement; const observer = new MutationObserver(() => { if (!document.body.contains(rootElement)) { observer.disconnect(); cleanupObserverRef.current = null; setDestroyed(true); } }); observer.observe(cleanupTarget, { childList: true, subtree: false }); cleanupObserverRef.current = observer; }); }, [token, destroyed, config, portalId, startDate, loadWidgetContent], ); useEffect(() => { // Create portal div if it doesn't exist if (!document.getElementById(portalId)) { const portalDiv = document.createElement('div'); portalDiv.id = portalId; document.body.appendChild(portalDiv); portalRef.current = portalDiv; } return () => { const node = portalRef.current; if (node && node.parentNode) { node.parentNode.removeChild(node); } portalRef.current = null; }; }, [portalId]); useEffect(() => { // Add fonts const uid = 'adalong-fonts'; const fonts = 'Fustat:400,700|Dangrek' if (!document.getElementById(uid)) { const link = document.createElement('link'); link.id = uid; link.rel = 'stylesheet'; link.href = `https://fonts.googleapis.com/css?family=${fonts}&display=swap`; document.head.appendChild(link); } // Register widget in global API window.adalongWidgetAPI ??= { widgets: [] }; window.adalongWidgetAPI.version = __WIDGET_VERSION__; window.adalongWidgetAPI.widgets.push({ id, layout, loaded, load, getSlideState, changePost, setSlider, setDebug, setHasAnalyticsConsent: updateHasAnalyticsConsent, ...events, }); // Cleanup return () => { // Stop the keep-alive monitoring if (keepAliveRef.current) { keepAliveRef.current.stop(); keepAliveRef.current = null; } if (cleanupObserverRef.current) { cleanupObserverRef.current.disconnect(); cleanupObserverRef.current = null; } // Unmount the React root we created for the host element. if (reactRootRef.current) { const { root } = reactRootRef.current; reactRootRef.current = null; queueMicrotask(() => { try { root.unmount(); } catch (e) { Debug.log('Root unmount failed', e); } }); } const index = window.adalongWidgetAPI.widgets.findIndex((w) => w.id === id); if (index > -1) { window.adalongWidgetAPI.widgets.splice(index, 1); } }; }, [id, portalId, load, getSlideState, changePost, setSlider, events]); return { id, layout, loaded, load, getSlideState, changePost, setSlider, setDebug, setHasAnalyticsConsent: updateHasAnalyticsConsent, ...events, }; }; const setHasAnalyticsConsent = (granted: boolean): void => { AnalyticsConsent.set(granted); }; export { initializeAdalongWidget, useAdalongWidget, setHasAnalyticsConsent }; export type { AdalongWidgetInstance };