visualizer.js

import {LAYOUTS, POSITIONS, VISUALIZER_EVENTS, PRIVATE_EVENTS, EXTERANL_CALLBACKS} from './constants';
import {WizartEvent, EventBus} from './event-bus';
import {validateLayoutSettings, validateDataType, isValidUrl, isBase64} from './utils';

/**
 * @ignore
 */
const generateQueryString = (token, queryData) => {
    const mappings = {
        uploadPhoto: (val) => `deactivate_custom_photo=${val ? '0' : '1'}`,
        showCatalog: (val) => `is_hide_catalog=${val ? '0' : '1'}`,
        showBackToWebsiteButton: (val) => `bba=${val ? '1' : '0'}`,
        twoWaySyncFavorites: (val) => `enable_two_way_favorites=${val ? '1' : '0'}`,
        openInRoom: (val) => `room_uuid=${val}`,
        openWithProduct: (val) => `article_query=${encodeURIComponent(JSON.stringify({vendor_code: val}))}`,
        openWithCustomProduct: (val) => `custom_apply={"product":${JSON.stringify(val)}}`,
        openWithCollection: (val) => `article_query=${JSON.stringify({collection_name: encodeURIComponent(val),})}`,
        userId: (val) => `user_id=${val}`,
        catalogFilter: (val) => `catalogFilter=${val}`,
        default: (key, val) => `${key}=${val}`
    };

    const queryParams = [`api_token=${token}`];
    Object.entries(queryData).forEach(([key, value]) => {
        if (value !== undefined && mappings[key]) {
            queryParams.push(mappings[key](value));
        } else if (value !== undefined) {
            queryParams.push(mappings.default(key, value));
        }
    });

    return `?${queryParams.join('&')}`;
};

/**
 * @typedef {Array<ShoppingCartProduct>} ShoppingCartState
 */

/**
 * Product sku.
 * @typedef {string} VendorCode
 */

/**
 * @typedef {Array<VendorCode>} FavoritesState
 */

/**
 * @typedef ShoppingCartProduct
 * @property {VendorCode} vendor_code
 * @property {number} quantity
 */

/**
 * @typedef {Object} OptionalProperties
 * @property {boolean} [overlap] - Indicates if the product's design allows overlap.
 * @property {number} [pattern_offset] - The offset for the pattern in specified units.
 * @property {boolean} [repeatable] - Indicates if the product pattern is repeatable.
 * @property {boolean} [customized_size] - Indicates if the product can have a customized size.
 * @property {string} [preview_path] - The path to a preview image for the product.
 * @property {string} [link] - A URL link related to the product (e.g., product details).
 * @property {string} [brand_name] - A product brand name.
 * @property {string} [collection_name] - A product collection name.
 */

/**
 * Represents a custom product not loaded to PIM, which may be used in Visualizer.
 * @memberof WizartDeploymentKit
 * @class
 */
class CustomProduct {

    /**
     * Creates a new CustomProduct instance.
     * @constructor
     * @param {ProductType} type - The type of the product.
     * @param {string} name - The name of the product.
     * @param {string} vendor_code - A unique code identifying the product from the vendor.
     * @param {string} unit - The unit of measurement for the product dimensions.
     * @param {number} width - The width of the product in specified units.
     * @param {number} height - The height of the product in specified units.
     * @param {string} image_path - The path to the product's main image.
     * @param {OptionalProperties} [optional={}] - Additional optional properties for the product.
     */
    constructor(type, name, vendor_code, unit, width, height, image_path, optional={overlap, pattern_offset, repeatable, customized_size, preview_path, link, brand_name, collection_name}) {
        this.type = type;
        this.name = name;
        this.vendor_code = vendor_code;
        this.unit = unit;
        this.width = width;
        this.height = height;
        this.image_path = image_path;

        Object.assign(this, optional);
    }

    /**
     * @ignore
     */
    getDefinedProperties() {
        const definedProperties = {};

        for (const [key, value] of Object.entries(this)) {
            if (value !== undefined) {
                definedProperties[key] = value;
            }
        }

        return definedProperties;
    }
}

/**
 * Class representing a Visualizer for embedding a customizable iframe component.
 * @class
 * @memberof WizartDeploymentKit
 */
class Visualizer {
    /**
    * @typedef {Object} LayoutSettings
    * @property {HTMLElement} targetElement - Target element for the Visualizer iframe.
    * @property {number} [layout=LAYOUTS.FULL_SCREEN] - Layout type (from LAYOUT).
    * @property {number} [position=POSITION.REPLACE] - Position (from POSITION).
    * @property {number} [width] - Width for custom layout.
    * @property {number} [height] - Height for custom layout.
    */

    /**
    * @typedef {Object} IntegrationSettings
    * @property {boolean} [uploadPhoto=true] - Enables or disables the custom photo upload feature.
    * @property {boolean} [showCatalog=true] - Shows or hides the product catalog section in the Visualizer.
    * @property {boolean} [showBackToWebsiteButton=true] - Enables or disables the "Back to Website" button.
    * @property {boolean} [twoWaySyncFavorites=false] - Enables or disables two way sync for favorites.
    * @property {string} [userId] - Identifier for the user interacting with the Visualizer, for tracking user sessions.
    * @property {string} [locale] - Sets the localization language for the Visualizer (e.g., "en" for English).
    * @property {string} [context] - Contextual information relevant to the current Visualizer session.
    */

    /**
    * @typedef {Object} SceneData
    * @property {string} [openInRoom] - UUID of the room to open by default in the Visualizer.
    * @property {string} [openWithProduct] - Identifier for a product to be preselected in the Visualizer (e.g., "Decal 01 2d").
    * @property {CustomProduct} [openWithCustomProduct] - CustomProduct instance to be preselected in the Visualizer.
    * @property {string} [openWithCollection] - Name of the collection to preselect in the product catalog (e.g., "Carpet Rolls").
    */

    /**
    * @typedef {Object} VisualizerOptions
    * @property {string} token - Authentication token for the Visualizer.
    * @property {LayoutSettings} layoutSettings - Settings for the Visualizer's layout.
    * @property {IntegrationSettings} [integrationSettings={}] - Additional integration settings.
    * @property {SceneData} [sceneData={}] - Initial data to load in the Visualizer.
    * @property {boolean} [load=true] - Whether to load the iframe on Visualizer instance creation.
    */

    /**
    * Creates a new Visualizer instance.
    * @constructor
    * @param {VisualizerOptions} options - The options for the Visualizer instance.
    * @example
    * // Simple usage
    * const targetElementVisualizer = document.getElementById('visualizer');
    * const visualizer = new WizartDeploymentKit.Visualizer({ token: 'ANSUhEUgAA...', layoutSettings: { targetElement: targetElementVisualizer } });
    * await visualizer.load();
    * visualizer.show();
    * 
    * // Usage with custom product
    * const visualizer = new WizartDeploymentKit.Visualizer({
     *     token: 'ANSUhEUgAA...', // Your access token
     *     layoutSettings: {
     *         targetElement: targetElementVisualizer // Parent element for Visualizer
     *     }
     *     sceneData: {
    *
    *         // set openInRoom if you want to open the transferred product in a specific room. Otherwise, it will start with the room selection.
    *         openInRoom: "a3c6086f-4aaa-4...",
    * 
    *         openWithCustomProduct: WizartDeploymentKit.Visualizer.CustomProduct(
    *             WizartDeploymentKit.PRODUCT_TYPES.WALLPAPER,
    *             'Your product name',
    *             'Product vendor code',
    *             'm',
    *             'product width',
    *             'product height',
    *             
    *             // product texture url || base64 string
    *             WizartDeploymentKit.formatBase64ImageSrc("iVBORw0KGgoAAAANSUhEUgAA...")
    *         );
    *     }
    * });
    * await visualizer.load();
    * visualizer.show();
    * 
    * // See more configuration options in the VisualizerOptions.
    */
    constructor(options = {}) {  
        const {
            token,
            layoutSettings = {},
            integrationSettings = {},
            sceneData = {},
            load = false
        } = options;
        this.token = token;

        /** 
         * The iframe element embedded in the document.
         * @type {HTMLIFrameElement} 
         * @public
         */
        this.iframe = null;

        validateLayoutSettings(layoutSettings);
        validateDataType(integrationSettings);
        validateDataType(sceneData);

        this.layoutSettings = layoutSettings;
        this.integrationSettings = integrationSettings;
        this.integrationSettings.uploadPhoto = this.integrationSettings.uploadPhoto ?? true;
        this.integrationSettings.showCatalog = this.integrationSettings.showCatalog ?? true;
        this.integrationSettings.showBackToWebsiteButton = this.integrationSettings.showBackToWebsiteButton ?? true;
        this.integrationSettings.twoWaySyncFavorites = this.integrationSettings.twoWaySyncFavorites ?? false;

        this.sceneData = sceneData;
        this.layout = this.layoutSettings.layout || LAYOUTS.FULL_SCREEN;
        this.position = this.layoutSettings.position || POSITIONS.REPLACE;

        /** 
         * Determine if iframe is loaded.
         * @type {boolean} 
         * @public
         */
        this.isLoaded = false;

        /** 
         * Determine if Visualizer is visible.
         * @type {boolean} 
         * @public
         */
        this.isVisible = false;

        this.customProduct = {};

        // if (load) {
        //     this.loadIframe();
        // }
    }

    /**
     * Sets a callback function to filter product details properties.
     * @param {Function} cb - A function to filter product details.
     * @throws {Error} Throws an error if the provided argument is not a function.
     * 
     * @example
     * visualizer.filterProductDetailsProps = (properties) => {
     *     const propertiesToShow = {
     *         width: (v) => `${v}m`,
     *         height: (v) => `${v}m`,
     *         price: (v) => `${v}$ for m2`
     *     };
     *     const filtered = properties.filter(prop => Object.keys(propertiesToShow).includes(Object.keys(prop)[0]));
     *     return filtered
     *         .map(prop => {
     *             const [key, value] = Object.entries(prop)[0];
     *             return { [key]: propertiesToShow[key](value) };
     *         });
     * };
     */
    set filterProductDetailsProps(cb) {
        if (typeof cb !== 'function') {
            console.error(`${filterProductDetailsProps} should has function type.`);
        }
        const cb_string = cb.toString();
        this._externalCallbacks[EXTERANL_CALLBACKS.FILTER_DETAILS_PAGE_PROPS] = cb_string;
        if (this.isLoaded) {
            this._pushEvent(new WizartEvent(
                PRIVATE_EVENTS.EXTERNAL_CALLBACK, 
                {
                    name: EXTERANL_CALLBACKS.FILTER_DETAILS_PAGE_PROPS,
                    callback: cb_string
                })
            );
        }
    }

    /**
     * Reloads the Visualiser iframe with new scene data.
     * @param {SceneData} sceneData - The new initial data.
     * @returns {Promise<Visualizer>} A promise that resolves when the iframe is loaded.
     */
    updateSceneData(sceneData) {
        this.sceneData = sceneData;
        this.isLoaded = false;

        if (!this.isVisible && !this.iframe) {
            this._pendingSceneData = sceneData;
            return Promise.resolve(this);
        }

        return this.loadIframe();
    }

    /**
     * @ignore
     */
    loadIframe() {
        if (this.isLoaded) return Promise.resolve();

        return new Promise((resolve, reject) => {
            this._loadPromiseQueue.push({ resolve, reject });

            if (!this.iframe) {
                this.iframe = document.createElement("iframe");

                switch (this.position) {
                    case POSITIONS.BEFORE:
                        this.layoutSettings.targetElement.before(this.iframe);
                        break;
                    case POSITIONS.AFTER:
                        this.layoutSettings.targetElement.append(this.iframe);
                        break;
                    case POSITIONS.REPLACE:
                        this.layoutSettings.targetElement.replaceChildren(this.iframe);
                }

                this.iframe.name = "wizartFittingRoom";
                this.iframe.allowFullscreen = true;
                this.iframe.allow = "web-share;clipboard-write"
                this.iframe.classList.add('wizart-shared');
                this.iframe.classList.add(this.layout);
                this.iframe.style.opacity = "0";
                this.iframe.style.pointerEvents = "none";
            } 
            let base64;
            let queryData;
            if (this.sceneData.openWithCustomProduct && isValidUrl(this.sceneData.openWithCustomProduct.image_path)) {
                queryData = {...this.integrationSettings, openWithCustomProduct: this.sceneData.openWithCustomProduct.getDefinedProperties(), openInRoom: this.sceneData.openInRoom, openWithCollection: this.sceneData.openWithCollection};
            } else if (this.sceneData.openWithCustomProduct && isBase64(this.sceneData.openWithCustomProduct.image_path)) {
                const customProduct = this.sceneData.openWithCustomProduct.getDefinedProperties();
                base64 = customProduct.image_path;
                customProduct.image_path = '';
                customProduct.is_base64 = true;
                queryData = {...this.integrationSettings, openWithCustomProduct: customProduct, openInRoom: this.sceneData.openInRoom, openWithCollection: this.sceneData.openWithCollection};
            } else {
                queryData = {...this.integrationSettings, ...this.sceneData};
            }
            const newSrc = `https://pim-client.wizart.ai/fitting-room${generateQueryString(this.token, queryData)}&locale=en`;
            if (newSrc !== this.iframe.src) {
                this.iframe.src = `https://pim-client.wizart.ai/fitting-room${generateQueryString(this.token, queryData)}`;
            }

            this.iframe.onload = () => {
                if (this.integrationSettings.showBackToWebsiteButton) {
                    const BBSSubscriptionID = EventBus.subscribe(() => {
                        setTimeout(() => this.destroy(), 0);
                    }, VISUALIZER_EVENTS.BACK_BUTTON_CLICK, this);

                    this._registerLocalSubscription(BBSSubscriptionID);
                }

                const downloadSubscriptionId = EventBus.subscribe(
                    handleDownloadRender,
                    PRIVATE_EVENTS.DOWNLOAD_RENDER,
                    this
                );
                this._registerLocalSubscription(downloadSubscriptionId);

                const qrDownloadSubscriptionId = EventBus.subscribe(
                    qrDownload,
                    PRIVATE_EVENTS.DOWNLOAD_QR,
                    this
                );
                this._registerLocalSubscription(qrDownloadSubscriptionId);

                this.isLoaded = true;
                this._setExternalCallbacks();
                if (base64) {
                    this._pushEvent(PRIVATE_EVENTS.SET_CUSTOM_PRODUCT, {image_path: base64});
                }
                
                this._loadPromiseQueue.forEach(({ resolve }) => resolve(this));
                this._loadPromiseQueue = [];
            };
    
            this.iframe.onerror = () => {
                reject(new Error("Failed to load iframe"));
            };
        });
    }

    /**
     * Loads the Visualizer iframe if not already loaded.
     * @returns {Promise<Visualizer>} A promise that resolves when the iframe is loaded.
     */
    load() {
        return;
    }

    /**
     * Retrieves the current shopping cart state.
     * @returns {Promise<ShoppingCartState>} A promise that resolves with the shopping cart state data.
     */
    getShoppingCartState() {
        return new Promise((resolve, reject) => {
            this._getShoppingCartPromiseQueue.push({ resolve, reject });

            this.loadIframe().then(() => {
                const subscriptionId = EventBus.subscribe(
                    (event) => {
                        this._getShoppingCartPromiseQueue.forEach(({ resolve }) => resolve(event.data));
                        this._getShoppingCartPromiseQueue = [];
                        EventBus.unsubscribe(subscriptionId);
                    },
                    PRIVATE_EVENTS.GET_SHOPPING_CART_STATE,
                    this
                );
                EventBus.pushEvent(this, new WizartEvent(PRIVATE_EVENTS.GET_SHOPPING_CART_STATE));
            });
        });
    }

    /**
     * Retrieves the current favorites state.
     * @returns {Promise<FavoritesState>} A promise that resolves with the favorites state data.
     */
    getFavoritesState() {
        return new Promise((resolve, reject) => {
            this._getFavoritesPromiseQueue.push({ resolve, reject });

            this.loadIframe().then(() => {
                const subscriptionId = EventBus.subscribe(
                    (event) => {
                        this._getFavoritesPromiseQueue.forEach(({ resolve }) => resolve(event.data));
                        this._getFavoritesPromiseQueue = [];
                        resolve(event.data);
                        EventBus.unsubscribe(subscriptionId);
                    },
                    PRIVATE_EVENTS.FAVORITES_STATE,
                    this
                );
                EventBus.pushEvent(this, new WizartEvent(PRIVATE_EVENTS.GET_FAVORITES_STATE));
            });
        });
    }

    /**
     * Sets new state for Visualizer shopping cart.
     * @param {ShoppingCartState} state
     * @returns {Promise<ShoppingCartState>}
     */
    setShoppingCartState(state) {
        return new Promise((resolve, reject) => {
            this.loadIframe().then(() => {
                const subscrId = EventBus.subscribe(
                    (event) => {
                        EventBus.unsubscribe(subscrId);
                        resolve(event.data);
                    },
                    VISUALIZER_EVENTS.SHOPPING_CART_STATE_CHANGE,
                    this
                );

                EventBus.pushEvent(
                    this,
                    new WizartEvent(
                        PRIVATE_EVENTS.SET_SHOPPING_CART_STATE,
                        state
                    )
                );
            });
        });
    }
    /**
     * Sets new state for Visualizer favorites.
     * @param {FavoritesState} state 
     * @returns {Promise<FavoritesState>}
     */
    setFavoritesState(state) {
        return new Promise((resolve, reject) => {
            this.loadIframe().then(() => {
                const subscrId = EventBus.subscribe(
                    (event) => {
                        EventBus.unsubscribe(subscrId);
                        resolve(event.data);
                    },
                    VISUALIZER_EVENTS.FAVORITES_STATE_CHANGE,
                    this
                );

                EventBus.pushEvent(
                    this,
                    new WizartEvent(
                        PRIVATE_EVENTS.SET_FAVORITES_STATE,
                        state
                    )
                );
            });
        });
    }

    /**
     * Sets custom dimensions for the Visualizer iframe in CUSTOM_SIZE layout.
     * @param {number} width - The width of the iframe.
     * @param {number} height - The height of the iframe.
     * @throws {Error} Throws an error if layout is not CUSTOM_SIZE.
     */
    setSize(width, height) {
        if (this.layout !== Visualizer.LAYOUT.CUSTOM_SIZE) {
            console.error(`Visualizer size can not be changed for Visualizer layout ${this.layout}.`);
        } else {
            this.iframe.style.width = `${width}px`;
            this.iframe.style.height = `${height}px`;
        }
    }

    /**
     * Shows the Visualizer iframe.
     */
    async show() {
        if (this._pendingSceneData) {
            const data = this._pendingSceneData;
            this._pendingSceneData = null;
            this.sceneData = data;
            this.isLoaded = false;
        }

        await this.loadIframe();

        if (this.iframe) {
            this.iframe.style.opacity = "1";
            this.iframe.style.pointerEvents = "auto";
            this.isVisible = true;
        }

        return this;
    }

    /**
     * Hides the Visualizer iframe.
     */
    hide() {
        if (this.iframe && this.isVisible) {
            this.iframe.style.display = "none";
            this.isVisible = false;
        }
    }

    /**
     * Prevent user actions in visualizer.
     * @returns {void}
     */
    block() {
        this.iframe.classList.add('wizart-blocked');
    }

    /**
     * Allow user actions in Visualizer.
     * @returns {void}
     */
    unblock() {
        this.iframe.classList.remove('wizart-blocked');
    }

    /**
     * Destroys the Visualizer iframe instance, removing event subscriptions and clearing memory.
     */
    destroy() {
        this.hide();
        this.isLoaded = false;

        if (this.iframe) {
            const iframe = this.iframe;
            setTimeout(() => {
                iframe.remove();
            }, 200);
            delete this.iframe;
        }

        setTimeout(() => {
            this._localSubscriptionIds.forEach(subscriptionID => EventBus.unsubscribe(subscriptionID));
            this._localSubscriptionIds = [];
        }, 150);
    }
}

/**
 * @ignore
 */
Visualizer.prototype._externalCallbacks = {};

/**
 * @ignore
 */
Visualizer.prototype._localSubscriptionIds = [];


/**
 * @ignore
 */
Visualizer.prototype._loadPromiseQueue = [];

/**
 * @ignore
 */
Visualizer.prototype._getShoppingCartPromiseQueue = [];

/**
 * @ignore
 */
Visualizer.prototype._getFavoritesPromiseQueue = [];

/**
 * @ignore
 */
Visualizer.prototype._pushEvent = function(eventName, payload) {
    if (this.isLoaded && this.iframe) {
        this.iframe.contentWindow.postMessage({ eventName, payload }, this._getOrigin());
    } else {
        console.warn(`Target Visualizer is not loaded!`);
    }
};

/**
 * @ignore
 */
Visualizer.prototype._getOrigin = function() {
    const iframeURL = new URL(this.iframe.src);
    return iframeURL.origin;
};

/**
 * @ignore
 */
Visualizer.prototype._registerLocalSubscription = function(subscriptionId) {
    this._localSubscriptionIds.push(subscriptionId);
};

/**
 * @ignore
 */
Visualizer.prototype._setExternalCallbacks = function() {
    Object.entries(this._externalCallbacks).forEach(([name, cb]) => {
        this._pushEvent(new WizartEvent(
            PRIVATE_EVENTS.EXTERNAL_CALLBACK, 
            {
                name: name,
                callback: cb
            })
        );
    });
};

/**
 * @ignore
 */
function handleDownloadRender(event) {
    const { filename, file } = event.data;

    if (!file) {
        console.error('[Download Render] No file provided in payload');
        return;
    }

    const extension = file.type && file.type.includes('/')
        ? file.type.split('/')[1]
        : 'bin';

    const safeFilename = filename || `download_${Date.now()}.${extension}`;
    const url = URL.createObjectURL(file);

    triggerDownload(url, safeFilename);
}

/**
 * @ignore
 */
function qrDownload(event) {
    const { filename, file, mimeType = 'image/png' } = event.data;
    console.log('Received event: ', event);

    if (!file) {
        console.error('[QR Download] No file provided in payload');
        return;
    }

    const base64Data = file.includes(',') ? file.split(',')[1] : file;
    const byteCharacters = atob(base64Data);
    const byteNumbers = new Uint8Array(byteCharacters.length);

    for (let i = 0; i < byteCharacters.length; i++) {
        byteNumbers[i] = byteCharacters.charCodeAt(i);
    }

    const blob = new Blob([byteNumbers], { type: mimeType });
    const extension = mimeType.includes('/') ? mimeType.split('/')[1] : 'bin';
    const safeFilename = filename || `qr_${Date.now()}.${extension}`;
    const url = URL.createObjectURL(blob);

    triggerDownload(url, safeFilename);
}

function triggerDownload(url, filename) {
    const downloadLink = document.createElement('a');
    downloadLink.href = url;
    downloadLink.download = filename;
    document.body.appendChild(downloadLink);
    downloadLink.click();
    document.body.removeChild(downloadLink);

    URL.revokeObjectURL(url);
}

/**
 * @ignore
 */
Visualizer.prototype._isInstanceOfVisualizer = true;

export { Visualizer, CustomProduct };