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