class SWChatEmbedder { constructor(baseUrl, chatbotId,iframeContainerId='iframe-sw-chat', initialWidth = '400px', initialHeight = '600px',debug=false) { this.baseUrl = baseUrl; this.version="?v=1.2.4.2.1"; this.iframeContainerId = iframeContainerId; this.chatIframe = null; this.iconIframe = null; this.iframeContainer = null; this.debug = debug; this.isFullscreen = false; this.initialWidth = initialWidth; this.initialHeight = initialHeight; this.iconWidth = '75px'; this.iconHeight = '75px'; this.iconFontSize = null; this.iconSizeBreakpoints = []; this.chatBotId=chatbotId; this.hasIconFrame=false; this.hasUI=true; this.startMessage = "Waarmee kan ik je helpen?"; this.suggestions = ["Schrijf een bericht..."]; this.additionalFormdata = {}; this.internalsOverride={}; } setVersion(version) { this.version="?"+version; } /** * Merges the provided data object into the existing additionalFormdata. * Logs warnings or updated formdata if debug mode is enabled. * * This is used for when the application that uses the chatclients wants to sent extra information to the backend initized from the main application * * @param {Object} data - The data object to be added to the existing additionalFormdata. * Must be a non-null object. * @return {void} */ addFormdata(data) { if (typeof data !== 'object' || data === null) { if (this.debug) console.warn("addFormdata: expected an object"); return; } // Merge new data into existing additionalFormdata this.additionalFormdata = { ...this.additionalFormdata, ...data }; if (this.debug) console.log("Updated formdata:", this.additionalFormdata); } addInternalsOverride(data) { if (typeof data !== 'object' || data === null) { if (this.debug) console.warn("addInternalsOverride: expected an object"); return; } // Merge new data into existing additionalFormdata this.internalsOverride = { ...this.internalsOverride, ...data }; console.log(this.internalsOverride); if (this.debug) console.log("Updated internalsOverride:", this.internalsOverride); } uiIsAvailable() { return false; } /** * activate debug on demand */ setDebug () { this.debug=true; } setStartMessage(message) { this.startMessage=message; } /** * Registers a breakpoint-driven icon iframe size. * Call before init() to affect initial render. Multiple calls are allowed. * * @param {number} breakpointPx - Max viewport width in px where this size applies. * @param {string|number} width - Width in px or any valid CSS size. * @param {string|number} height - Height in px or any valid CSS size. * @param {string|number|null} fontSize - Optional font size for the icon. * @return {void} */ setIconFrameSize(breakpointPx, width, height, fontSize = null) { const normalizedBreakpoint = this.normalizeBreakpoint(breakpointPx); if (normalizedBreakpoint === null) { if (this.debug) console.warn("setIconFrameSize: expected breakpoint in px (number or 'NNNpx')"); return; } this.iconSizeBreakpoints.push({ breakpoint: normalizedBreakpoint, width: this.normalizeSize(width, '75px'), height: this.normalizeSize(height, '75px'), fontSize: fontSize === null ? null : this.normalizeSize(fontSize, '') }); this.iconSizeBreakpoints.sort((a, b) => a.breakpoint - b.breakpoint); this.applyIconFrameSize(); } /** * Normalizes size inputs to valid CSS strings. * * @param {string|number} value - A number (px) or CSS size string. * @param {string} fallback - Fallback size if value is invalid. * @return {string} */ normalizeSize(value, fallback) { if (typeof value === 'number' && !Number.isNaN(value)) { return `${value}px`; } if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '') { return fallback; } if (/^\d+(\.\d+)?$/.test(trimmed)) { return `${trimmed}px`; } return trimmed; } return fallback; } /** * Normalizes breakpoint inputs to a number of pixels. * * @param {string|number} value - A number or a 'NNNpx' string. * @return {number|null} */ normalizeBreakpoint(value) { if (typeof value === 'number' && !Number.isNaN(value)) { return value; } if (typeof value === 'string') { const trimmed = value.trim(); if (/^\d+(\.\d+)?px$/.test(trimmed)) { return parseFloat(trimmed.replace('px', '')); } if (/^\d+(\.\d+)?$/.test(trimmed)) { return parseFloat(trimmed); } } return null; } /** * Applies the icon size based on current viewport width and breakpoints. * * @return {void} */ applyIconFrameSize() { let width = '75px'; let height = '75px'; let fontSize = null; if (this.iconSizeBreakpoints.length > 0) { const viewportWidth = window.innerWidth || 0; const matches = this.iconSizeBreakpoints.filter((item) => viewportWidth >= item.breakpoint); if (matches.length > 0) { const match = matches[matches.length - 1]; width = match.width; height = match.height; fontSize = match.fontSize; } } this.iconWidth = width; this.iconHeight = height; this.iconFontSize = fontSize; if (this.iconIframe) { this.iconIframe.style.width = width; this.iconIframe.style.height = height; if (fontSize) { this.applyIconFontSize(fontSize); } } } /** * Applies the icon font size inside the icon iframe. * * @param {string} fontSize - CSS font-size value. * @return {void} */ applyIconFontSize(fontSize) { try { if (!this.iconIframe || !this.iconIframe.contentDocument) { return; } const icon = this.iconIframe.contentDocument.querySelector('.sw-chat-icon'); if (icon) { icon.style.fontSize = fontSize; } } catch (e) { if (this.debug) console.warn("applyIconFontSize: unable to set font size"); } } /** * Initializes the iframe container and the iframes for the icon and chat. */ init(show_icon=true) { this.hasUI=this.uiIsAvailable(); if (this.debug) { console.log(`Initializing iframe container and iframes. `); } this.createIframeContainer(); if (show_icon) this.createIconIframe(); this.hasIconFrame=show_icon; this.createChatIframe(); this.addMessageListener(); this.adjustHeightOnResize(); } /** * Creates the container element where the iframes will be embedded. * If the container with the specified ID already exists, it will not create a new one. */ createIframeContainer() { // Check if the container already exists let container = document.getElementById(this.iframeContainerId); if (!container) { if (this.debug) { console.log(`Creating new iframe container.`); } // Create a new container element container = document.createElement('div'); container.id = this.iframeContainerId; container.classList.add("state-closed"); // Add the container to the body or another specified element document.body.appendChild(container); // Dynamically create a stylesheet const style = document.createElement('style'); style.type = 'text/css'; // Add the CSS rules for the container const css = ` :root { --sw-chat-max-width-${this.iframeContainerId}: ${this.initialWidth}; } #${this.iframeContainerId} { position: fixed; bottom: 10px; right: 20px; z-index: 99999999; } #${this.iframeContainerId}.state-open { width:100%; max-width: min(calc(100vw - 60px), var(--sw-chat-max-width-${this.iframeContainerId})); } #${this.iframeContainerId}.state-closed { width:auto; } @media (min-width: 1920px) { #${this.iframeContainerId} { right: calc((100vw - 1900px) / 2); } } /* Scroll lock on parent when chat is fullscreen */ html.sw-chat-scroll-lock, body.sw-chat-scroll-lock { overflow: hidden !important; height: 100% !important; overscroll-behavior: none !important; } /* iOS fix: body fixed, bewaar positie via inline top */ body.sw-chat-scroll-lock { position: fixed !important; width: 100% !important; } @keyframes sw-chat-slide-open { 0% { opacity: 0; transform: translateY(10px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes sw-chat-slide-close { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(10px); } } .sw-chat-frame.sw-opening { animation: sw-chat-slide-open 0.3s ease-out forwards; } .sw-chat-frame.sw-closing { animation: sw-chat-slide-close 0.25s ease-in forwards; } `; style.appendChild(document.createTextNode(css)); // Append the stylesheet to the document head document.head.appendChild(style); } else if (this.debug) { console.log(`Iframe container already exists.`); } this.iframeContainer = container; } /** * Creates and configures the iframe for the chat icon. */ createIconIframe() { if (this.debug) { console.log(`Creating icon iframe.`); } // Create the icon iframe element this.iconIframe = document.createElement('iframe'); this.iconIframe.className = `sw-chat-icon-master sw-chat-icon-${this.chatBotId}`; // Set other iframe properties as needed (e.g., size, src, etc.) this.iconIframe.style.border = 'none'; this.applyIconFrameSize(); // Add a random number query parameter to URLs if debug is enabled const randomQuery = this.debug ? `?rand=${Math.floor(Math.random() * 100000)}` : ''; // Set the HTML content of the icon iframe using Font Awesome this.iconIframe.srcdoc = ` Chat Icon
`; // Append the icon iframe to the container this.iframeContainer.appendChild(this.iconIframe); this.iconIframe.addEventListener('load', () => { this.applyIconFrameSize(); this.triggerIconWiggleIfNeeded(); }); } /** * Creates and configures the iframe for embedding the chat client. * The chat iframe is hidden by default. */ createChatIframe() { if (this.debug) { console.log(`Creating chat iframe.`); } // Create the chat iframe element this.chatIframe = document.createElement('iframe'); this.chatIframe.style.width = "100%"; this.chatIframe.height = this.initialHeight; this.chatIframe.style.border = 'none'; this.chatIframe.style.display = 'none'; this.chatIframe.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.2)'; // Optional styling for better visibility this.chatIframe.className = `sw-chat-frame sw-chat-frame-${this.chatBotId}`; this.chatIframe.id=`sw-chat-frame-${this.chatBotId}`; this.chatIframe.setAttribute('allow', 'clipboard-read; clipboard-write'); // Add a random number query parameter to URLs if debug is enabled const randomQuery = this.debug ? `?rand=${Math.floor(Math.random() * 100000)}` : ''; const chatState = localStorage.getItem('chatState'+this.chatBotId); var smallScreen=""; if (window.innerWidth<768) smallScreen="sw-smallscreen"; // Set the HTML content of the chat iframe this.chatIframe.srcdoc = ` Chat Client `; // Append the chat iframe to the container this.iframeContainer.appendChild(this.chatIframe); if (chatState=="open") this.openChat(); } /** * Opens the chat iframe and hides the icon iframe. * Saves the state to localStorage. */ openChat() { if (this.debug) { console.log(`Opening chat iframe.`); } if (this.hasIconFrame) this.iconIframe.style.display = 'none'; const frame = this.chatIframe; frame.style.display = 'block'; frame.classList.remove('sw-closing'); frame.classList.add('sw-opening'); // na animatie: reset class (anders triggert hij niet opnieuw later) setTimeout(() => frame.classList.remove('sw-opening'), 300); this.iframeContainer.classList.add("state-open"); this.iframeContainer.classList.remove("state-closed"); localStorage.setItem('chatState' + this.chatBotId, 'open'); this.chatIframe.contentWindow.postMessage({action: 'openChat', id: "${this.chatBotId}"}, '*'); if (this.isFullscreen) this.uiHide(true); // Automatically toggle fullscreen if screen width is less than 768px if (window.innerWidth < 768) { this.toggleFullscreenChat(true); } this.adjustChatHeight(); } /** * Closes the chat iframe and shows the icon iframe. * Saves the state to localStorage. * if fake then only the state is set. this is used for clicking on a link */ closeChat(fake=false) { if (this.debug) { console.log(`Closing chat iframe.`); } if (this.isFullscreen) { this.toggleFullscreenChat(false); } if (!fake) { const frame = this.chatIframe; frame.classList.remove('sw-opening'); frame.classList.add('sw-closing'); setTimeout(() => { frame.style.display = 'none'; frame.classList.remove('sw-closing'); if (this.hasIconFrame) this.iconIframe.style.display = 'block'; this.uiHide(false); this.iframeContainer.classList.add("state-closed"); this.iframeContainer.classList.remove("state-open"); this.triggerIconWiggleIfNeeded(); }, 200); // sluit na animatie } localStorage.setItem('chatState'+this.chatBotId, 'closed'); } /** * ui hiding stub can be implemented when necessary * @param hide * @returns {boolean} */ uiHide (hide=false) { if (!this.hasUI) return false; return true; } /** * Updates the suggestions list with the provided array of suggestions. * * @param {Array} suggestions - An array of suggestion items to set as the current suggestions. * @return {void} Does not return any value. */ setSuggestions(suggestions) { this.suggestions=suggestions; } /** * Toggles the fullscreen mode of the chat iframe. * @param {boolean} force - If true, forces the chat iframe to fullscreen. */ toggleFullscreenChat(force = false) { if (force || !this.isFullscreen) { // ---- ENTER FULLSCREEN ---- this.uiHide(true); // Maak iframe echt `fullscreen` this.chatIframe.style.position = 'fixed'; this.chatIframe.style.top = '0'; this.chatIframe.style.left = '0'; this.chatIframe.style.width = '100vw'; this.chatIframe.style.height = '100vh'; this.chatIframe.style.zIndex = '100000000'; // boven alles // Scroll lock parent this._scrollY = window.scrollY || window.pageYOffset || 0; document.documentElement.classList.add('sw-chat-scroll-lock'); document.body.classList.add('sw-chat-scroll-lock'); // iOS/Safari body-fix: body naar boven "plakken" document.body.style.top = `-${this._scrollY}px`; this.isFullscreen = true; } else { // ---- EXIT FULLSCREEN ---- this.uiHide(false); this.chatIframe.style.position = 'relative'; this.chatIframe.style.top = ''; this.chatIframe.style.left = ''; this.chatIframe.style.width = this.initialWidth; this.chatIframe.style.height = this.initialHeight; this.chatIframe.style.zIndex = '1000'; // Scroll unlock parent document.documentElement.classList.remove('sw-chat-scroll-lock'); document.body.classList.remove('sw-chat-scroll-lock'); const y = this._scrollY || 0; document.body.style.top = ''; window.scrollTo(0, y); this.isFullscreen = false; } } /** * Adds an event listener to handle messages from the chat iframe. */ addMessageListener() { window.addEventListener('message', (event) => { if (event.data.id==this.chatBotId) { if (event.data.action === 'openChat') { this.openChat(); } else if (event.data.action === 'closeChat') { this.closeChat(event.data.fake); } else if (event.data.action === 'toggleFullscreenChat') { this.toggleFullscreenChat(event.data.force); } } }); } isMobileDevice() { return window.innerWidth < 768; } /** * Adjusts the chat iframe height to fit the screen height if it exceeds the viewport. */ adjustChatHeight() { if (this.isFullscreen) return; const availableHeight = window.innerHeight - 80; // Subtracting margin var chatIframe=document.getElementById(`sw-chat-frame-${this.chatBotId}`); if (parseInt(this.initialHeight) > availableHeight) { chatIframe.height = `${availableHeight}px`; } else { chatIframe.height = this.initialHeight; } } /** * Adds an event listener to adjust the height of the chat iframe when the window is resized. */ adjustHeightOnResize() { window.addEventListener('resize', () => { if (!this.isFullscreen) { this.adjustChatHeight(); } this.applyIconFrameSize(); }); } /** * Triggers a 10-second wiggle animation once per day when the icon becomes visible. * * @return {void} */ triggerIconWiggleIfNeeded() { if (!this.hasIconFrame || !this.iconIframe) { return; } const trackKey = `chatTrack${this.chatBotId}`; const today = this.getTodayKey(); const track = this.readTrackState(trackKey); if (track && track.date === today && track.wiggled === true) { return; } this.writeTrackState(trackKey, {date: today, wiggled: true}); this.applyIconWiggle(10000); } /** * Applies the wiggle animation for the given duration. * * @param {number} durationMs * @return {void} */ applyIconWiggle(durationMs) { try { if (!this.iconIframe || !this.iconIframe.contentDocument) { return; } const icon = this.iconIframe.contentDocument.querySelector('.sw-chat-icon'); if (!icon) { return; } icon.style.animation = 'pulse 1s infinite ease-in-out'; setTimeout(() => { icon.style.animation = ''; }, durationMs); } catch (e) { if (this.debug) console.warn("applyIconWiggle: unable to start animation"); } } /** * Reads the tracking state from localStorage. * * @param {string} key * @return {Object|null} */ readTrackState(key) { try { const raw = localStorage.getItem(key); if (!raw) { return null; } const data = JSON.parse(raw); if (typeof data !== 'object' || data === null) { return null; } return data; } catch (e) { return null; } } /** * Writes the tracking state to localStorage. * * @param {string} key * @param {Object} value * @return {void} */ writeTrackState(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { if (this.debug) console.warn("writeTrackState: unable to save state"); } } /** * Returns a YYYY-MM-DD string for today. * * @return {string} */ getTodayKey() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } }