import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Api } from './services/api'; import { Widget } from './components/Widget'; import { IData, ISettings, IDebug, Sources, Config, MinContentEvent } from './types'; import { Debug } from './debug'; import { Helpers } from './services/helpers'; import { DEFAULT_CONFIG, initial, StateSetter, StoreProvider, StoreState } from './services/store'; import { WidgetEvents } from './services/events'; class AdalongWidget extends WidgetEvents { /** * Layout of the widget: mosaic, carousel or wall * This information is known once the widget has been correctly loaded */ public layout?: ISettings['type']; public loaded: boolean = false; public storeState: { getState: () => StoreState; stateSetter: StateSetter } = { getState: () => initial, stateSetter: () => {} }; private _id = Helpers.randomId(); private token?: string; private startDate = new Date(); private config: Config; /* React Portals variables */ private postViewerPortalId?: string; private portalDiv?: HTMLDivElement; /** * @param token Widget token */ constructor(token?: string, config?: Config) { super(); this.config = { ...DEFAULT_CONFIG, ...config, }; if (this.config.id !== undefined) { this._id = this.config.id; } this.token = token; this.createAdalongAPI(); this.emitWidgetCreated(); this.createPostViewerPortal(); Debug.try(() => { this.addFonts(); }); } /** * Get the readonly widget identifier */ public get id() { return this._id; } public getSlideState(): { canSlideLeft: boolean; canSlideRight: boolean } { return { canSlideLeft: this.storeState.getState().canSlideLeft, canSlideRight: this.storeState.getState().canSlideRight, }; } public changePost(dir: 'left' | 'right') { dispatchEvent(new CustomEvent('adalongWidget_changePost', { detail: dir })); } public setSlider(dir: 'left' | 'right') { dispatchEvent(new CustomEvent('adalongWidget_slide', { detail: dir })); } /** * Creates a div in the dom for the widget modal (react portals) */ private createPostViewerPortal() { this.postViewerPortalId = `adalong-postViewer-${this._id}`; this.portalDiv = document.createElement('div'); this.portalDiv.id = this.postViewerPortalId; document.body.appendChild(this.portalDiv); } public async load(element: string | HTMLElement, settings?: Partial) { if (!this.token) { throw new Error('No token provided'); } if (this.destroyed) { // avoid reloaded an old destroyed instance return; } const token: string = this.token; await Debug.try(async () => { const rootElement = this.getRootElement(element); const sources = this.getSources(rootElement); const data: IData | null = await this.loadWidgetContent(token, sources, settings).catch((err) => { Debug.error(err); return null; }); if (!data) { return; } this.layout = data.settings.type; ReactDOM.render( StoreState, setter: StateSetter) => { this.storeState.getState = getState; this.storeState.stateSetter = setter; }, data: data ? { ...data } : undefined, root: rootElement, forceMobile: rootElement.getAttribute('data-forcemobile') === 'true', isMobile: Helpers.isMobile(rootElement.clientWidth), triggerEvent: this.triggerEvent.bind(this), config: this.config, }} > , rootElement ); this.loaded = true; this.observeWidgetRemoval(rootElement); }); } public setDebug(debug: IDebug) { if (!debug) { return; } Debug.try(() => { const { level, apiUrl } = debug; if (level) { Debug.setLevel(level); } if (apiUrl) { Api.apiUrl = apiUrl; } }); } /** * Clean widget after removal */ protected destroy(): void { // destroy events super.destroy(); // remove react portal postviewer div this.portalDiv && document.body.removeChild(this.portalDiv); const index = window.adalongWidgetAPI.widgets.indexOf(this); if (index > -1) { // remove widget from api window.adalongWidgetAPI.widgets.splice(index, 1); } this.destroyed = true; } // /** // * Add observer to destroy this widget instance when the element's is removed // * It will wait for the page to be fully loaded // */ private observeWidgetRemoval(element: string | HTMLElement): void { const htmlElement: HTMLElement | null = typeof element === 'string' ? document.querySelector(element) : element; const observe = () => { window.removeEventListener('load', observe); const observer = new MutationObserver(() => { // directly check if the element is still in the dom const stillExists = document.body.contains(htmlElement); if (!stillExists) { observer.disconnect(); this.destroy(); } }); observer.observe(document.body, { subtree: true, childList: true }); }; observe(); } private async loadWidgetContent(token: string, sources?: Sources, settings?: Partial): Promise { if (!token) { throw new Error('No settings or token provided'); } const defaultSettings: ISettings = await Api.getSettings(token); const finalSettings: ISettings = { ...defaultSettings, ...settings, }; const content = await Api.getContent(token, sources, finalSettings); if (!content) { throw new Error('No content returned'); } content.medias = content.medias.map((media, i) => ({ ...media, postIndex: i })); if (finalSettings.max_media) { content.medias = content.medias.slice(0, finalSettings.max_media); } const minContent: MinContentEvent = { required: finalSettings.min_media_to_display, current: content.medias.length, }; if (minContent.required && minContent.current < minContent.required) { if (this.hasSubscribed('minContentNotReached')) { this.triggerEvent('minContentNotReached', minContent); } else { console.debug('Minimum content not reached', minContent); } return null; } else { this.triggerEvent('minContentReached', minContent); } return { settings: finalSettings, content }; } private getRootElement(element: string | HTMLElement): HTMLElement { if (!element) { throw new Error('A valid css selector or a dom element must be provided'); } else if (typeof element === 'string') { const e = document.querySelector(element); if (!e) { throw new Error(`Root '${element}' element not found`); } return e; } return element; } private addFonts() { const uid = 'adalong-fonts'; const fonts = 'Montserrat+Subrayada|Montserrat:400,500,700|Be+Vietnam+Pro:300'; if (!document.getElementById(uid)) { let 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); } } /** * Return sources set as attributes in the given dom element */ private getSources(element: HTMLElement): Sources | undefined { try { return { productIds: element.dataset.products ? Helpers.isValidJson(element.dataset.products) ? JSON.parse(element.dataset.products) : element.dataset.products.split(',') : undefined, collectionIds: element.dataset.collections ? Helpers.isValidJson(element.dataset.collections) ? JSON.parse(element.dataset.collections) : element.dataset.collections.split(',') : undefined, }; } catch (e) { console.error(e); return {}; } } /** * Emit a custom event 'adalongWidgetCreated' when a widget has been created */ private emitWidgetCreated() { const event = new CustomEvent('adalongWidgetCreated', { detail: { widget: this, }, }); document.dispatchEvent(event); } /** * Create the api to manipulate the widget(s) */ private createAdalongAPI(): void { window.adalongWidgetAPI ??= { widgets: [] }; window.adalongWidgetAPI.widgets.push(this); } } export = AdalongWidget;