UNPKG

salsify-experiences-sdk

Version:

SDK to be used by commerce websites to implement product experiences.

343 lines 13.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.attachIframeContextListener = void 0; const request_1 = __importStar(require("../utils/request")); const runtime_1 = require("../utils/runtime"); const perProductConfig_1 = require("./perProductConfig"); const iframeId = 'salsify-ec-iframe'; const prodCdnOrigin = 'https://salsify-ecdn.com'; const stagingCdnOrigin = 'https://staging.salsify-ecdn.com'; const testEnvCdnPattern = /^https:\/\/.+.test\.salsify\.com/; const cdnPrefix = 'sdk'; // The message types also used in enhanced-content-renderer for posting messages const messageTypes = { heightUpdateRequest: 'heightUpdateRequest', contextRequest: 'contextRequest', }; const defaultSource = 'index.html'; let attachedIframeEventListener = false; function messageFromSalsifyCDN(event) { return event.origin === prodCdnOrigin || event.origin === stagingCdnOrigin || testEnvCdnPattern.test(event.origin); } const attachIframeResizeListener = (logger) => { if (attachedIframeEventListener) { return; } window.addEventListener('message', event => { if (!messageFromSalsifyCDN(event)) { return; } // if `messageType` isn't defined, it means the EC is an older version and sent // a height update request without the type included, so we need to handle it if (event.data.messageType && event.data.messageType !== messageTypes.heightUpdateRequest) { return; } const selector = `#${iframeId}`; const iframe = document.querySelector(selector); if (iframe) { iframe.height = event.data.height; } else { logger.log('error', { errorContext: 'iframeResizeListener', errorType: 'dom', errorMessage: `Could not find iframe with selector ${selector}`, }); } }); attachedIframeEventListener = true; }; /** * MessageEventSource can be one of the following: WindowProxy, MessagePort, or ServiceWorker. * For some browsers, WindowProxy is not defined, and its not equal to Window. * The postMessage method for them has different method argument. * WindowProxy have a second argument for targetOrigin, while other two's second argment is WindowPostMessageOption Object. * Therefore, we will want to know what the source type is so we can call the postMessage method correctly. */ function messageEventSourceIsWindow(source) { return ((!('MessagePort' in window) || !(source instanceof MessagePort)) && (!('ServiceWorker' in window) || !(source instanceof ServiceWorker)) && !!source.postMessage); } /** @internal */ const attachIframeContextListener = (context) => { window.addEventListener('message', event => { if (!event.source || !messageFromSalsifyCDN(event)) { return; } if (event.data.messageType !== messageTypes.contextRequest) { return; } // If event has channel ports, post the messages back via the ports. if (event.ports.length) { event.ports.forEach(port => { port.postMessage(context); }); } else if (messageEventSourceIsWindow(event.source)) { event.source.postMessage(context, event.origin); } else { event.source.postMessage(context); } }); }; exports.attachIframeContextListener = attachIframeContextListener; /** * The Enhanced Content API. * * This API is used to check for the existence of and to render Enhanced Content. */ class EnhancedContentApi { #perProductConfigCache; #existsCache = new Map(); #lastRenderConfig; #settings; #context; #logger; #options; /** @internal */ constructor(settings, context, logger, options) { this.#settings = settings; this.#context = context; this.#logger = logger; this.#options = options; this.#perProductConfigCache = new perProductConfig_1.PerProductConfigCache(logger); } /** * Checks if Enhaned Content exists for the given combination of `productId` and `idType` * * This is an asynchronous request and uses a promise-based API. You must wait for the promise to resolve and use the * _resolved_ value to determine existence of EC. This can be done using async/await or a `then` callback. * * @example * Using `async/await`: * ```typescript * const ecExists = await salsify.enhancedContent.exists("123", "SKU"); * if (ecExists) { * // ...render EC * } * ``` * * @example * Using a `then` callback: * ```typescript * salsify.enhancedContent.exists("123", "SKU").then((ecExists) => { * if (ecExists) { * // ...render EC * } * }); * ``` * * @param productId The string ID for the product. * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}. * @returns `true` if the product has Enhanced Content to display, `false` otherwise */ async exists(productId, idType) { idType = this.#checkAndGetIdType(idType); const cdnPath = this.#buildCdnPath(productId, idType); const config = await this.#perProductConfigCache.getConfig(cdnPath); const source = this.#getSource(config, cdnPath, productId); const content = config?.content ?? null; const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath); const sourceExists = !!source && (await this.#checkExists(cdnPath, source)); this.#lastRenderConfig = { idType, productId, content, allContentExists, source, sourceExists, }; this.#logger.log('ec_exists', this.#lastRenderConfig); return sourceExists; } /** * Renders Enhanced Content for the given `productId` and `idType` into an * iFrame that is inserted into the provided `container` element. * * This can only be called in a browser runtime context. * * The Salsify SDK is responsible for creating the `IFrame` element and synchronizing the * height based on content changes. * * @example * ```javascript * const salsify = window.salsifyExperiencesSdk; * const element = document.getElementById('enhanced-content-container'); * salsify.enhancedContent.renderIframe(element, productId, idType); * ``` * * @param container The DOM element within which to render Enhanced Content. * @param productId The string ID for the product. * @param idType The identifier type for the product; defaults to the value set {@link "api".SdkApi.init | on `init`}. */ async renderIframe(container, productId, idType) { if (!(0, runtime_1.inBrowser)()) { throw new Error('Can only render EC iframe in a browser runtime context.'); } if (this.#options?.beforeRender) { this.#options.beforeRender(productId, idType); } idType = this.#checkAndGetIdType(idType); const cdnPath = this.#buildCdnPath(productId, idType); const config = await this.#perProductConfigCache.getConfig(cdnPath); const source = this.#getSource(config, cdnPath, productId); if (source !== null) { attachIframeResizeListener(this.#logger); const iframe = this.#createIframe(); iframe.src = this.#buildContentUrl(cdnPath, source); iframe.title = 'Salsify Enhanced Content'; container.appendChild(iframe); } const content = config?.content ?? null; const allContentExists = await this.#checkAllContentExists(config?.content, cdnPath); const sourceExists = !!source && (await this.#checkExists(cdnPath, source)); this.#lastRenderConfig = { idType, productId, content, allContentExists, source, sourceExists, }; this.#logger.log('ec_render_iframe', this.#lastRenderConfig); this.#options?.afterRender?.(productId, idType); } /** * Updates the language code for subsequent Enhanced Content requests. * * This method can be used when a user updates the page language, and will * change the language of the content _without re-rendering_. * * To update the currently displayed content, the client application will * need to re-render the content after calling this method. * * @example * ```javascript * salsify.enhancedContent.updateLanguageCode('fr-CA'); * * // this call now uses the new language code, "fr-CA" * salsify.enhancedContent.exists(productId, idType); * ``` * * @param languageCode The language code to use for subsequent calls. */ updateLanguageCode(languageCode) { this.#settings = { ...this.#settings, languageCode }; this.#logger.log('ec_update_language_code', { languageCode }); } /** @internal */ get lastRenderConfig() { return this.#lastRenderConfig; } #checkAndGetIdType(idType) { if (idType) { return idType; } if (this.#settings.enhancedContent.idType) { return this.#settings.enhancedContent.idType; } throw new Error('No ID type specified.'); } #createIframe() { const iframe = document.createElement('iframe'); iframe.id = iframeId; iframe.height = '0'; iframe.width = '100%'; iframe.style.border = '0'; iframe.scrolling = 'no'; return iframe; } #buildCdnOrigin() { if (this.#settings.cdnRoot && testEnvCdnPattern.test(this.#settings.cdnRoot)) { return this.#settings.cdnRoot; } else if (this.#settings.staging) { return stagingCdnOrigin; } else { return prodCdnOrigin; } } #buildCdnPath(productId, idType) { const cdnOrigin = this.#buildCdnOrigin(); return `${cdnOrigin}/${cdnPrefix}/${this.#settings.clientId}/${this.#settings.languageCode}/BTF/${idType}/${productId}`; } #buildContentUrl(cdnPath, source = defaultSource) { return `${cdnPath}/${source}`; } #getSource(config, cdnPath, productId) { if (config && this.#settings.tracking) { const { content } = config; const { sessionId } = this.#context; if (sessionId) { const selectedSource = (0, perProductConfig_1.selectSource)({ productId, content, sessionId }); return selectedSource.source; } } return defaultSource; } async #checkExists(cdnPath, source) { const cacheKey = `${cdnPath}/${source}`; if (!this.#existsCache.has(cacheKey)) { const contentUrl = this.#buildContentUrl(cdnPath, source); let response; try { response = await request_1.default.head(contentUrl); } catch (error) { const errorMessage = error instanceof Error ? error.message : undefined; this.#logger.log('error', { errorContext: 'exists', errorType: 'fetch', errorMessage: `Error on HEAD request of ${contentUrl}: ${errorMessage}`, }); return false; } this.#existsCache.set(cacheKey, response.headers.get(request_1.Header.ContentLength) !== '0'); } return this.#existsCache.get(cacheKey) || false; } async #checkAllContentExists(content, cdnPath) { if (!content) { return false; } let hasNonNullSource = false; for (const src of content) { if (src.source) { hasNonNullSource = true; const sourceExists = await this.#checkExists(cdnPath, src.source); if (!sourceExists) { return false; } } } return hasNonNullSource; } } exports.default = EnhancedContentApi; //# sourceMappingURL=index.js.map