import paperBoatSvg from '../assets/logo.svg?raw'; // @ts-nocheck // TypeScript declarations for global variables /** @type {string} */ window.replypierId; /** @type {string} */ window.replypierBaseUrl; /** @type {object} Public widget API — see the assignment at the bottom of this file. */ window.ReplyPier; /** @type {object|undefined} Declarative options, same shape as ReplyPier.init(options). */ window.replypierConfig; ;(function () { // Function to validate hex color code function isValidHexColor(color) { return /^#[0-9A-F]{6}$/i.test(color); } // Resolve the backend base URL at RUNTIME so a single built replypier.min.js // works for every deployment without a rebuild. Priority: // 1. window.replypierBaseUrl — set by the install snippet the dashboard // generates, so a self-hosted embed reaches the right backend even though // this script runs on the customer's own site (where config.js is absent). // 2. window.APP_CONFIG.API_URL — present when embedded inside our own SPA. // 3. __REPLYPIER_API_URL__ — the default baked at build time (see the // build-webclient scripts). Only this default changes per build; the // resolution logic above stays runtime, so env changes need no rebuild. function getBaseUrl() { if (typeof window !== 'undefined' && window.replypierBaseUrl) { return window.replypierBaseUrl; } if (typeof window !== 'undefined' && window.APP_CONFIG && window.APP_CONFIG.API_URL) { return window.APP_CONFIG.API_URL; } if (typeof __REPLYPIER_API_URL__ !== 'undefined') { return __REPLYPIER_API_URL__; } // Final fallback — default to production so a plain build ships working. return 'https://api.replypier.site/api/v1'; } // Configuration object const config = { baseUrl: getBaseUrl(), containerId: 'replypier-container', backdropId: 'replypier-backdrop', buttonId: 'replypier-button', chatBubbleColor: '#f34611', // Default color loadingContainerId: 'replypier-loading', tokenKey: 'rpid', // Key for localStorage tokenProvider: null, // Host callback that mints a fresh conversation token containerBottom: 100, // Default bottom position containerRight: 20, // Default right position containerWidth: 400, // Default width containerHeight: 560, // Default height launcherBottom: 20, // Launcher button distance from the bottom edge (configurable) launcherRight: 20, // Launcher button distance from the active side edge (configurable) side: 'right', // Which screen edge the launcher/window hug: 'right' | 'left' displayMode: 'floating', // 'floating' | 'sidebar-left' | 'sidebar-right' | 'search-bar' launcher: true, // false hides the built-in launcher (site uses its own trigger/API) sidebarWidth: 420, // Panel width in the sidebar display modes searchBarWidth: 320, // Trigger bar width in search-bar mode searchPlaceholder: '有问题随时问我…', zIndex: 999999, // Stacking base; related layers offset from it (badge -1, mobile chrome +1/+2) trigger: null, // Optional CSS selector — matching elements toggle the chat on click hotkey: true, // ⌘K opens the Ask AI palette; set false if the site owns that key surfaceIsPalette: null, // Reported by the widget (WIDGET_SURFACE); null until then chatStyle: null, // Reported by the widget; decides whether it draws its own close chatInitiationMessages: [], // Will be populated from widget data initiationMessageId: 'replypier-initiation', initiationShownKey: 'rpim_shown', // Key for tracking if initiation was shown unreadCount: 0, // unread agent messages (reported by the iframe) once chat opened hasOpened: false, // whether the visitor has opened the chat at least once } // Keys the developer set explicitly (via replypierConfig / init / setPosition). // Dashboard defaults arriving later over CUSTOMIZATION_UPDATE never override these — // precedence is: developer options > dashboard settings > built-in defaults. const devOverrides = {} function setDevConfig(key, value) { config[key] = value devOverrides[key] = true } const DISPLAY_MODES = ['floating', 'sidebar-left', 'sidebar-right', 'search-bar'] // Chat styles that render the Ask AI surface (centered command palette) instead of // a corner chat window. Mirrors isAskAnythingStyle in WidgetBuilder.vue. const ASK_AI_STYLES = ['ASK_ANYTHING', 'AURORA'] // Palette defaults, used only when no explicit size was configured. const ASK_AI_WIDTH = 680 const ASK_AI_HEIGHT = 620 // Ceiling: the palette grows to fit its content up to this. const ASK_AI_MIN_HEIGHT = 140 // Whether a size came from the developer or the dashboard, as opposed to the // built-in default. The Ask AI palette has its own defaults but must still honour // an explicitly configured size. const sizeIsExplicit = { width: false, height: false } // The palette is the surface for the ask-anything styles, and for the search-bar // trigger whatever the style — a box labelled "Ask anything" that opens a corner // chat window is incoherent. An explicit drawer choice still wins. // Last height the palette asked for, so a window resize can re-clamp it against the // new 76vh ceiling instead of leaving the box at a stale size. let paletteContentHeight = 0 function isAskAiModal() { // The widget reports which surface it actually rendered (WIDGET_SURFACE); it // falls back to the chat panel for ratings, product cards and workflow forms, // and that needs the ordinary window geometry, not a hugged palette. if (config.surfaceIsPalette === false) return false const askSurface = ASK_AI_STYLES.indexOf(config.chatStyle) !== -1 || config.displayMode === 'search-bar' return askSurface && config.displayMode !== 'sidebar-left' && config.displayMode !== 'sidebar-right' } // Size the container to the palette's reported content height, clamped. Called on // every report and on window resize (the 76vh ceiling moves with the viewport). // Clears the inline height entirely for non-palette surfaces, so a hugged height // can never linger on an ordinary chat window. function applyPaletteHeight() { const container = document.getElementById(config.containerId) if (!container) return if (!isAskAiModal() || !paletteContentHeight) { container.style.height = '' return } const max = Math.min(paletteSize().height, Math.round(window.innerHeight * 0.76)) container.style.height = Math.max(ASK_AI_MIN_HEIGHT, Math.min(max, paletteContentHeight)) + 'px' } function paletteSize() { return { width: sizeIsExplicit.width ? config.containerWidth : ASK_AI_WIDTH, height: sizeIsExplicit.height ? config.containerHeight : ASK_AI_HEIGHT, } } // Dashboard "Widget placement" defaults (customization_metadata.widget_display, // validated server-side) arriving over CUSTOMIZATION_UPDATE. Fills only the keys // the developer didn't set. Types are re-checked because this crosses a frame. function applyDashboardDisplay(display) { if (!display || typeof display !== 'object') return const set = function (configKey, value) { if (!devOverrides[configKey]) config[configKey] = value } if (DISPLAY_MODES.indexOf(display.mode) !== -1) set('displayMode', display.mode) if (display.side === 'left' || display.side === 'right') set('side', display.side) if (typeof display.launcher === 'boolean') set('launcher', display.launcher) if (isNumber(display.width)) { set('containerWidth', Math.max(280, display.width)) if (!devOverrides.containerWidth) sizeIsExplicit.width = true } if (isNumber(display.height)) { set('containerHeight', Math.max(400, display.height)) if (!devOverrides.containerHeight) sizeIsExplicit.height = true } if (isNumber(display.sidebar_width)) set('sidebarWidth', Math.max(280, display.sidebar_width)) if (typeof display.search_placeholder === 'string' && display.search_placeholder) { set('searchPlaceholder', display.search_placeholder.slice(0, 80)) } if (isNumber(display.offset_bottom)) set('launcherBottom', Math.max(0, display.offset_bottom)) if (isNumber(display.offset_side)) set('launcherRight', Math.max(0, display.offset_side)) if (isNumber(display.z_index)) set('zIndex', Math.max(1, Math.floor(display.z_index))) } // Geometry the widget interior needs to adapt its own sizing (see WidgetBuilder). // Reports what the container actually is, which in Ask AI mode is the palette // rather than the configured floating-window size. function widgetDisplayPayload() { const palette = isAskAiModal() const size = palette ? paletteSize() : { width: config.containerWidth, height: config.containerHeight } return { type: 'WIDGET_DISPLAY', mode: palette ? 'ask-ai' : config.displayMode, width: size.width, height: size.height, // So the palette's own ⌘K handler honours a site that turned the chord off. hotkey: config.hotkey !== false, } } // The chat window's open/close mechanics live inside initialize() (they need the // DOM elements it creates). This controller is populated there so the public API // below can reach them; calls made before then are queued and flushed on init. let controller = null const pendingCalls = [] let triggerClickQueued = false // The widget iframe's window, once created — message handlers accept commands // only from it, so other embedded frames (ads etc.) can't drive the widget. let widgetFrameWindow = null // The conversation the frame is in, reported by CHAT_CREATED. Kept so logout() and // a change of identity can close it server-side before the frame goes away. let currentSessionId = null // Set by destroy(): the site removed the widget on purpose, so nothing may // rebuild it or queue work against it until it calls reload(). let isDestroyed = false function withController(fn) { if (controller) { fn() } else if (!isDestroyed) { pendingCalls.push(fn) } } // Public event bus: 'ready' | 'open' | 'close' | 'unread'. // 'ready' fires once; late subscribers are invoked immediately (see on()). const listeners = {} let widgetReadyFired = false function emitEvent(name, payload) { const cbs = listeners[name] if (!cbs || !cbs.length) return cbs.slice().forEach(function (cb) { try { cb(payload) } catch (e) { /* a broken site listener must not break the widget */ } }) } // Pick a readable ink color (dark/light) for content sitting on a given bg. function onColor(hex) { const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim()) if (!m) return '#0B0C10' const n = parseInt(m[1], 16) const lum = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255 return lum > 0.6 ? '#0B0C10' : '#FFFFFF' } // The launcher stays invisible (`.replypier-pending`, see updateStyles) until // revealButton() runs, so the correct brand color is applied before it's ever seen — // no orange-then-green flash on a cold cache. Reveal fires on whichever happens // first: the real color arrives (CUSTOMIZATION_UPDATE), the widget fails to load // (so the "Chat Unavailable" card stays reachable), or a timeout as a last resort. let buttonRevealed = false let revealTimeoutId = null const onRevealCallbacks = [] function revealButton() { if (buttonRevealed) return buttonRevealed = true if (revealTimeoutId) { clearTimeout(revealTimeoutId) revealTimeoutId = null } const btn = document.getElementById(config.buttonId) if (btn) btn.classList.remove('replypier-pending') onRevealCallbacks.splice(0).forEach((cb) => { try { cb() } catch (e) { /* no-op */ } }) } // Launcher badge: show the nudge count until the chat is first opened, then the // count of unread agent messages reported by the iframe. Hidden when open/empty. function updateBadge() { const btn = document.getElementById(config.buttonId) if (!btn) return const badge = btn.querySelector('.rp-badge') if (!badge) return const count = config.hasOpened ? config.unreadCount : (config.chatInitiationMessages || []).length badge.textContent = count > 99 ? '99+' : String(count) badge.style.display = (count > 0 && !btn.classList.contains('active')) ? 'flex' : 'none' } // Create and inject styles function updateStyles() { const mode = config.displayMode const isSidebar = mode === 'sidebar-left' || mode === 'sidebar-right' // Sidebar modes pin the window to their own edge; everything else (launcher, // nudge, floating window) follows the configured side. const side = mode === 'sidebar-left' ? 'left' : (mode === 'sidebar-right' ? 'right' : config.side) // The search bar is shorter than the bubble launcher; dependent offsets track it. const launcherHeight = mode === 'search-bar' ? 48 : 64 // Every chat style draws a header chevron that closes the widget, except // ASK_ANYTHING and AURORA (see isAskAnythingStyle in WidgetBuilder.vue). const hasInPanelClose = ASK_AI_STYLES.indexOf(config.chatStyle) === -1 // Ask AI styles open as a centered command-palette overlay (⌘K), the pattern // people expect from an "Ask anything" surface — not a corner chat window. // Sidebar modes still win: an explicit drawer choice is more specific. const askAiModal = isAskAiModal() const palette = paletteSize() const style = document.createElement('style') style.id = 'replypier-styles' style.textContent = ` /* ===== Rounded-square launcher (design comp) ===== */ #${config.buttonId} { position: fixed; bottom: ${config.launcherBottom}px; ${side}: ${config.launcherRight}px; width: 64px; height: 64px; border-radius: 20px 20px 20px 6px; background: ${config.chatBubbleColor}; color: ${onColor(config.chatBubbleColor)}; box-shadow: 0 16px 40px -8px ${config.chatBubbleColor}cc, inset 0 0 0 1px rgba(255,255,255,0.08); cursor: pointer; z-index: ${config.zIndex}; display: flex; align-items: center; justify-content: center; transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease; animation: replypier-float 4s ease-in-out infinite; } #${config.buttonId}.active { animation: none; } /* Stay fully invisible until the org's real brand color is known (or the widget errors out / times out) — avoids ever flashing the wrong color. */ #${config.buttonId}.replypier-pending { opacity: 0; pointer-events: none; } #${config.buttonId}:hover { transform: scale(1.06); } /* Expanding rings (closed state only) */ #${config.buttonId} .rp-ring { position: absolute; inset: 0; border-radius: 20px 20px 20px 6px; border: 1.5px solid ${config.chatBubbleColor}; animation: replypier-ring 2.4s ease-out infinite; pointer-events: none; } #${config.buttonId} .rp-ring.r2 { animation-delay: 1.2s; } #${config.buttonId}.active .rp-ring { display: none; } /* Pulsing 3-dot mark (closed). Absolutely centered so it stays pinned to the button's center regardless of the button's display mode (flex on desktop, block when .mobile-closed) or viewport. */ #${config.buttonId} .rp-dots { display: flex; gap: 5px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } #${config.buttonId} .rp-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: replypier-pulse 1.5s ease-in-out infinite; } #${config.buttonId} .rp-dot:nth-child(2) { animation-delay: .18s; } #${config.buttonId} .rp-dot:nth-child(3) { animation-delay: .36s; } #${config.buttonId}.active .rp-dots { display: none; } /* Chevron (open) — absolutely centered, pinned to the button center. */ #${config.buttonId} .rp-chevron { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 26px; font-weight: 700; line-height: 1; color: currentColor; } #${config.buttonId}.active .rp-chevron { display: block; } /* Unread badge = number of nudges */ #${config.buttonId} .rp-badge { position: absolute; top: -5px; right: -5px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 999px; background: #FF8A73; color: #0B0C10; font: 700 11.5px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; align-items: center; justify-content: center; border: 2px solid #F5F3EE; box-shadow: 0 4px 12px rgba(0,0,0,0.25); display: none; } #${config.buttonId}.active .rp-badge { display: none; } #${config.buttonId}.loading .rp-dots, #${config.buttonId}.loading .rp-chevron { visibility: hidden; } #${config.buttonId}.loading:after { content: ''; position: absolute; width: 16px; height: 16px; top: 50%; left: 50%; margin: -8px 0 0 -8px; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: #fff; animation: replypier-spin 0.6s linear infinite; } /* Search-bar trigger content: hidden unless search-bar mode (below) shows it. */ #${config.buttonId} .rp-search { display: none; } ${mode === 'search-bar' ? ` @media (min-width: 769px) { /* ===== Search-bar mode: the launcher becomes a search-input-like bar. Same element, same click/toggle/badge logic. Mobile keeps the bubble. ===== */ #${config.buttonId} { width: ${config.searchBarWidth}px; height: 48px; border-radius: 24px; padding: 0 16px; background: #ffffff; color: #374151; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14), inset 0 0 0 1.5px ${config.chatBubbleColor}55; animation: none; justify-content: flex-start; box-sizing: border-box; } #${config.buttonId}:hover { transform: scale(1.02); } #${config.buttonId} .rp-ring, #${config.buttonId} .rp-dots, #${config.buttonId} .rp-chevron, #${config.buttonId}.active .rp-chevron { display: none; } /* Dark spinner — the stock white one vanishes on the white bar. */ #${config.buttonId}.loading .rp-search { visibility: hidden; } #${config.buttonId}.loading:after { border-color: rgba(0, 0, 0, 0.15); border-top-color: ${config.chatBubbleColor}; } #${config.buttonId} .rp-search { display: flex; align-items: center; gap: 10px; min-width: 0; width: 100%; } #${config.buttonId} .rp-search svg { flex-shrink: 0; color: ${config.chatBubbleColor}; } #${config.buttonId} .rp-search-placeholder { font: 400 14px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } ` : ''} @keyframes replypier-spin { to { transform: rotate(360deg); } } @keyframes replypier-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } @keyframes replypier-ring { 0% { transform: scale(.85); opacity: .5; } 100% { transform: scale(1.6); opacity: 0; } } @keyframes replypier-pulse { 0%, 100% { opacity: .45; } 50% { opacity: 1; } } @media (prefers-reduced-motion: reduce) { #${config.buttonId}, #${config.buttonId} .rp-ring, #${config.buttonId} .rp-dot, .initiation-orb { animation: none !important; } /* Open and close in place: no slide, scale or fade for people who asked for less motion. */ #${config.buttonId}, #${config.containerId}, #${config.backdropId} { transition: none !important; } } #${config.containerId} { position: fixed; /* The window sits above the launcher; when the launcher is moved (via ReplyPier.init position / setPosition) the window shifts by the same delta so the two stay together. Mobile is full-screen (media query below) and is unaffected. */ bottom: ${(config.containerBottom || 100) + ((config.launcherBottom || 20) - 20)}px; ${side}: ${(config.containerRight || 20) + ((config.launcherRight || 20) - 20)}px; width: ${config.containerWidth || 384}px; max-width: calc(100vw - 48px); height: ${config.containerHeight}px; max-height: calc(100vh - 132px); background: transparent; z-index: ${config.zIndex}; overflow: hidden; /* Animated open/close with slide + fade */ opacity: 0; visibility: hidden; transform: translateY(16px) scale(0.98); transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1), transform 360ms cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear 360ms; border: none; padding: 0; margin: 0; pointer-events: none; } #${config.containerId}.active { opacity: 1; visibility: visible; transform: translateY(0) scale(1); transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), transform 420ms cubic-bezier(0.22, 1, 0.36, 1); pointer-events: auto; } /* Backdrop element: always in the DOM, so keep it out of the host page's flow even when it is not in use — an unstyled div in
adds a phantom row to a flex/grid body layout. */ #${config.backdropId} { position: fixed; inset: 0; display: none; } ${askAiModal ? ` /* ===== Ask AI: centered command palette (⌘K), the pattern people expect from an "Ask anything" surface — dimmed page, top-anchored so the eye lands on the input rather than the middle of the screen. ===== */ #${config.containerId} { top: 12vh; bottom: auto; left: 50%; right: auto; width: min(${palette.width}px, calc(100vw - 32px)); max-width: none; /* A cross-document iframe can't size its embedder, so this is a starting height only: the palette measures its content and posts WIDGET_RESIZE, which sets an inline height (clamped to this ceiling). height:100% against an auto-height parent would collapse to the 150px replaced default. */ height: min(${palette.height}px, 76vh); max-height: 76vh; transform: translate(-50%, -8px) scale(0.98); z-index: ${config.zIndex + 1}; transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1), transform 360ms cubic-bezier(0.22, 1, 0.36, 1), height 200ms cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear 360ms; } #${config.containerId}.active { transform: translate(-50%, 0) scale(1); transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), transform 420ms cubic-bezier(0.22, 1, 0.36, 1), height 200ms cubic-bezier(0.22, 1, 0.36, 1); } #${config.containerId} .replypier-iframe { border-radius: 16px; } #${config.backdropId} { display: block; background: rgba(9, 9, 14, 0.45); backdrop-filter: blur(2px); z-index: ${config.zIndex}; opacity: 0; visibility: hidden; transition: opacity 220ms ease, visibility 0s linear 220ms; } #${config.backdropId}.active { opacity: 1; visibility: visible; transition: opacity 220ms ease; } /* The palette dims the page and closes on backdrop click / Esc, so the launcher would just be a stray button floating over the dim. */ #${config.buttonId}.active { display: none; } @media (max-width: 768px) { /* Full-screen on phones like every other mode. The main mobile block below pins the edges but never resets transform — without this the centering translate would shove the panel half a screen to the left. */ #${config.containerId}, #${config.containerId}.active { transform: none !important; } #${config.backdropId} { display: none; } } ` : ''} ${isSidebar ? ` /* ===== Sidebar mode: full-height drawer hugging the ${side} edge ===== */ #${config.containerId} { top: 0; bottom: auto; ${side}: 0; ${side === 'left' ? 'right: auto;' : 'left: auto;'} width: ${config.sidebarWidth}px; max-width: 100vw; height: 100dvh; max-height: 100dvh; transform: translateX(${side === 'left' ? '-100%' : '100%'}); box-shadow: ${side === 'left' ? '' : '-'}12px 0 40px rgba(0, 0, 0, 0.12); } #${config.containerId}.active { transform: translateX(0); } #${config.containerId} .replypier-iframe { border-radius: 0; } ${hasInPanelClose ? ` /* The drawer's own header chevron closes it, so keeping the launcher on screen would just park a duplicate button beside the panel. Hide it while open. */ #${config.buttonId}.active { display: none; } ` : ` /* ASK_ANYTHING / AURORA draw no in-panel chevron, and the drawer would paint over the launcher (later sibling, equal z-index) — leaving nothing to click. Keep it reachable: slide it out beside the drawer edge, one layer up. */ #${config.buttonId} { z-index: ${config.zIndex + 1}; transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease, left 360ms cubic-bezier(0.22, 1, 0.36, 1), right 360ms cubic-bezier(0.22, 1, 0.36, 1); } #${config.buttonId}.active { ${side}: ${config.sidebarWidth + 20}px; } `} ` : ''} /* Clean border around the widget container */ /* The widget panel (inside the iframe) draws its own theme-aware border, so the container no longer adds one — avoids a double border (and a white line on dark themes). */ .replypier-iframe { width: 100%; height: 100%; border: none; padding: 0; margin: 0; display: block; border-radius: 24px; /* Rely on corner shadows from container */ box-shadow: none; } #replypier-mobile-close { display: none; position: fixed; top: 20px; right: 20px; width: 44px; height: 44px; background: transparent; border: none; align-items: center; justify-content: center; cursor: pointer; z-index: ${config.zIndex + 1}; transition: all 0.3s ease; } #replypier-mobile-topbar { display: none; position: fixed; top: 0; left: 0; right: 0; height: 60px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(0, 0, 0, 0.1); z-index: ${config.zIndex}; align-items: center; justify-content: space-between; padding: 0 20px; box-sizing: border-box; } #replypier-mobile-topbar.active { display: flex; } #replypier-mobile-topbar .topbar-title { font-size: 16px; font-weight: 600; color: #333; margin: 0; } /* Only ASK_ANYTHING (which has no in-panel header chevron) uses the floating mobile-close; other styles close via the header chevron, so don't double up. */ .ask-anything-mobile #replypier-mobile-close.active { display: flex; } @media (max-width: 768px) { #${config.containerId} { width: 100vw !important; height: 100vh !important; height: 100dvh !important; /* Dynamic viewport height for mobile browsers */ /* Reset the desktop max-width/height clamp — without this the desktop rule's max-width: calc(100vw - 48px) / max-height: calc(100vh - 132px) keeps the window a floating panel instead of going full-screen on mobile. */ max-width: 100vw !important; max-height: 100vh !important; max-height: 100dvh !important; top: 0 !important; left: 0 !important; bottom: 0 !important; right: 0 !important; border-radius: 0 !important; position: fixed !important; margin: 0 !important; padding: 0 !important; } #${config.containerId}.active { bottom: 0 !important; } .replypier-iframe { border-radius: 0 !important; width: 100vw !important; height: 100vh !important; height: 100dvh !important; /* Dynamic viewport height for mobile browsers */ position: absolute !important; top: 0 !important; left: 0 !important; margin: 0 !important; padding: 0 !important; } #${config.buttonId} { display: none; } #${config.buttonId}.mobile-closed { /* flex (not block) keeps the launcher's centering context; the dots/chevron are absolutely centered regardless, but this keeps it consistent. */ display: flex !important; width: 56px !important; height: 56px !important; /* Respect the configured position on mobile too, so the closed launcher can clear a fixed bottom nav bar (the main reason to move it up). */ bottom: ${config.launcherBottom}px !important; ${side}: ${config.launcherRight}px !important; } .ask-anything-mobile #replypier-mobile-close.active { display: flex !important; } #replypier-mobile-close:hover { opacity: 0.7; } /* ASK_ANYTHING style specific mobile topbar */ .ask-anything-mobile #replypier-mobile-topbar.active { display: flex !important; } .ask-anything-mobile #replypier-mobile-close.active { top: 15px !important; right: 15px !important; z-index: ${config.zIndex + 2} !important; } /* When topbar is visible, push iframe down to avoid overlap */ .ask-anything-mobile .replypier-iframe { top: 60px !important; height: calc(100vh - 60px) !important; height: calc(100dvh - 60px) !important; } } @media (min-width: 769px) { #replypier-mobile-close { display: none !important; } } /* Chat Initiation Message Styles */ #${config.initiationMessageId} { position: fixed !important; /* Sit above the launcher (+12px gap) and align to its edge on the active side, so it never overlaps the icon regardless of the configured position/mode. */ bottom: ${(config.launcherBottom || 20) + launcherHeight + 12}px !important; ${side}: ${config.launcherRight || 20}px !important; max-width: 260px !important; background: white !important; padding: 12px 36px 12px 14px !important; border-radius: 14px !important; box-shadow: 0 3px 16px rgba(0, 0, 0, 0.1) !important; z-index: ${config.zIndex - 1} !important; cursor: pointer !important; opacity: 0; visibility: hidden; transform: translateY(10px) scale(0.95); transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1) !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; font-size: 14px !important; box-sizing: border-box !important; margin: 0 !important; border: none !important; display: block !important; width: auto !important; height: auto !important; min-width: 0 !important; min-height: 0 !important; max-height: none !important; line-height: 1.4 !important; text-align: left !important; vertical-align: baseline !important; overflow: visible !important; } /* Clamp the nudge text to 4 lines with an ellipsis so a long welcome/initiation message can never balloon the bubble over the page or into the launcher. Applied to the text node only, so the orb (left:-34px) is never clipped. */ #${config.initiationMessageId} .initiation-message-text { margin: 0 !important; display: -webkit-box !important; -webkit-line-clamp: 4 !important; -webkit-box-orient: vertical !important; overflow: hidden !important; } /* Siri-style orb avatar beside the nudge (design comp) */ .initiation-orb { position: absolute !important; left: -34px !important; bottom: 2px !important; width: 26px !important; height: 26px !important; border-radius: 50% !important; background: conic-gradient(from 0deg, #C9F24E, #9D8CFF, #5FE3D6, #FF8A73, #C9F24E) !important; box-shadow: 0 4px 14px rgba(157, 140, 255, 0.4) !important; animation: replypier-orb-spin 6s linear infinite !important; z-index: 1 !important; } @keyframes replypier-orb-spin { to { transform: rotate(360deg); } } ${side === 'left' ? ` /* On the left edge the orb (which protrudes 34px past the bubble) would clip off-screen — mirror it to the bubble's page-facing side. */ .initiation-orb { left: auto !important; right: -34px !important; } ` : ''} #${config.initiationMessageId}.show { opacity: 1; visibility: visible; transform: translateY(0) scale(1); animation: replypier-bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55); } @keyframes replypier-bounce-in { 0% { opacity: 0; transform: translateY(20px) scale(0.8); } 50% { transform: translateY(-5px) scale(1.02); } 100% { opacity: 1; transform: translateY(0) scale(1); } } #${config.initiationMessageId}:hover { transform: translateY(-2px) scale(1.02); box-shadow: 0 5px 20px rgba(0, 0, 0, 0.14); } #${config.initiationMessageId}::after { content: '' !important; position: absolute !important; bottom: -7px !important; ${side}: 30px !important; width: 14px !important; height: 14px !important; min-width: 14px !important; min-height: 14px !important; max-width: 14px !important; max-height: 14px !important; background: white !important; transform: rotate(45deg) !important; box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.06) !important; clip-path: polygon(0 0, 100% 0, 100% 100%) !important; border: none !important; margin: 0 !important; padding: 0 !important; box-sizing: border-box !important; z-index: -1 !important; } .initiation-message-text { font-size: 13px !important; line-height: 1.4 !important; color: #374151 !important; margin: 0 !important; padding: 0 4px 0 0 !important; position: relative !important; z-index: 1 !important; min-height: 18px !important; box-sizing: border-box !important; border: none !important; background: transparent !important; display: block !important; width: 100% !important; height: auto !important; max-width: none !important; max-height: none !important; text-align: left !important; vertical-align: baseline !important; font-weight: normal !important; font-style: normal !important; text-decoration: none !important; text-transform: none !important; letter-spacing: normal !important; word-spacing: normal !important; white-space: normal !important; overflow-wrap: break-word !important; word-wrap: break-word !important; } .initiation-message-text::after { content: '|'; animation: blink 1s step-end infinite; margin-left: 2px; color: ${config.chatBubbleColor}; font-weight: 500; } .initiation-message-text.typing-complete::after { display: none; } @keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } .initiation-close { position: absolute !important; top: 10px !important; right: 10px !important; width: 20px !important; height: 20px !important; min-width: 20px !important; min-height: 20px !important; max-width: 20px !important; max-height: 20px !important; background: rgba(0, 0, 0, 0.04) !important; border: none !important; border-radius: 5px !important; cursor: pointer !important; opacity: 0.5 !important; transition: all 0.2s ease !important; z-index: 2 !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important; margin: 0 !important; box-sizing: border-box !important; outline: none !important; box-shadow: none !important; text-decoration: none !important; font-size: 0 !important; line-height: 0 !important; overflow: hidden !important; flex-shrink: 0 !important; } .initiation-close:hover { opacity: 1 !important; background: rgba(0, 0, 0, 0.08) !important; transform: scale(1.05) !important; } .initiation-close::before, .initiation-close::after { content: '' !important; position: absolute !important; width: 9px !important; height: 1.5px !important; min-width: 9px !important; min-height: 1.5px !important; max-width: 9px !important; max-height: 1.5px !important; background: #4a5568 !important; border-radius: 1px !important; top: 50% !important; left: 50% !important; margin: -0.75px 0 0 -4.5px !important; padding: 0 !important; border: none !important; box-sizing: border-box !important; } .initiation-close::before { transform: rotate(45deg) !important; } .initiation-close::after { transform: rotate(-45deg) !important; } @media (max-width: 768px) { #${config.initiationMessageId} { display: none !important; } } /* Error UI Styles */ .replypier-error-ui { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-radius: 24px; overflow: hidden; position: relative; } .replypier-error-ui::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.08) 0%, transparent 50%), radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 50%); pointer-events: none; } .replypier-error-card { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 24px; text-align: center; position: relative; z-index: 1; max-width: 90%; } .replypier-error-close { position: absolute; top: 12px; right: 12px; z-index: 2; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border: none; border-radius: 50%; background: rgba(120, 120, 140, 0.12); color: #4b5563; font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.15s ease; } .replypier-error-close:hover { background: rgba(120, 120, 140, 0.22); } .replypier-error-icon-wrapper { display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); border-radius: 20px; margin-bottom: 20px; box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); } .replypier-error-icon { width: 36px; height: 36px; color: #6366f1; } .replypier-error-title { font-size: 20px; font-weight: 600; color: #1e293b; margin: 0 0 12px 0; letter-spacing: -0.01em; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } .replypier-error-message { font-size: 14px; line-height: 1.6; color: #64748b; margin: 0 0 20px 0; max-width: 280px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } .replypier-error-retry { min-width: 104px; min-height: 44px; padding: 10px 24px; margin: 0 0 22px; border: 0; border-radius: 12px; background: #4f46e5; color: #fff; font: 600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; cursor: pointer; } .replypier-error-retry:hover:not(:disabled) { background: #4338ca; } .replypier-error-retry:focus-visible { outline: 3px solid #818cf8; outline-offset: 3px; } .replypier-error-retry:disabled { opacity: 0.7; cursor: wait; } .replypier-error-footer { display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 12px; color: #94a3b8; opacity: 0.8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } .replypier-error-footer-link { color: inherit; text-decoration: none; cursor: pointer; } .replypier-error-footer-link:hover { text-decoration: underline; } @media (max-width: 768px) { .replypier-error-ui { border-radius: 0; } .replypier-error-card { padding: 24px 20px; } .replypier-error-icon-wrapper { width: 64px; height: 64px; border-radius: 16px; } .replypier-error-icon { width: 32px; height: 32px; } .replypier-error-title { font-size: 18px; } .replypier-error-message { max-width: 260px; } } ${config.launcher ? '' : ` /* Launcher hidden by config — the site opens the chat with its own trigger or the JS API. Last in the sheet so it also beats the mobile .mobile-closed re-show (display: flex !important at equal specificity). */ #${config.buttonId}, #${config.buttonId}.mobile-closed { display: none !important; } `} ` // Remove existing style if it exists const existingStyle = document.getElementById('replypier-styles') if (existingStyle) { existingStyle.remove() } document.head.appendChild(style) // The search-bar placeholder is data, not markup — keep it in sync here since // every config change funnels through updateStyles(). syncLauncherContent() } // Set text content on launcher children (textContent, never innerHTML — the // placeholder can come from dashboard settings). function syncLauncherContent() { const btn = document.getElementById(config.buttonId) if (!btn) return const placeholder = btn.querySelector('.rp-search-placeholder') if (placeholder && placeholder.textContent !== config.searchPlaceholder) { placeholder.textContent = config.searchPlaceholder } } // A token is only usable if it's a non-empty string. The stringified "undefined" // and "null" turn up routinely in site code that read an empty session. function normalizeToken(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return null; return trimmed; } // Get stored token - check window.replypierToken first (set by developer), // then fall back to localStorage (persisted from previous sessions) function getStoredToken() { return normalizeToken(window.replypierToken) || normalizeToken(localStorage.getItem(config.tokenKey)); } // The customer a token names, or null. Read-only: the backend still verifies every // token; this only decides whether an identify() is a change of person (rebuild) or // the same person with a fresher token (swap in place). The loader is a standalone // script with no imports, so it carries its own copy of this rather than sharing // the widget app's useConversationToken. function tokenSubject(token) { const value = normalizeToken(token); if (!value) return null; const payload = value.split('.')[1]; if (!payload) return null; try { const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); return claims.sub || claims.customer_id || null; } catch (e) { return null; } } // Save token function saveToken(token) { if (token) { localStorage.setItem(config.tokenKey, token); } } // Remove token function removeToken() { localStorage.removeItem(config.tokenKey); } // Check if device is mobile function isMobileDevice() { return window.innerWidth <= 768; } // Sanitize message to remove corrupted emoji characters function sanitizeMessage(message) { if (!message) return ''; // Remove replacement characters and other common corruption patterns return message .replace(/\uFFFD/g, '') // Remove replacement character .replace(/[��]/g, '') // Remove common corruption symbols .replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove control characters .trim(); } // Get a random message from the array function getRandomInitiationMessage() { if (!config.chatInitiationMessages || config.chatInitiationMessages.length === 0) { return null; } const randomIndex = Math.floor(Math.random() * config.chatInitiationMessages.length); const message = config.chatInitiationMessages[randomIndex]; return message ? sanitizeMessage(message) : null; } // Check if initiation message should be shown (first visit in session) function shouldShowInitiation() { // Only show on desktop if (isMobileDevice()) return false; // The nudge bubble is anchored to the launcher; with the launcher hidden it // would float over nothing, so skip it entirely. if (!config.launcher) return false; // Check if we have messages to show if (!config.chatInitiationMessages || config.chatInitiationMessages.length === 0) { return false; } // Check session storage to see if already shown in this session return !sessionStorage.getItem(config.initiationShownKey); } // Mark initiation as shown function markInitiationShown() { sessionStorage.setItem(config.initiationShownKey, 'true'); } // Create initiation message element function createInitiationMessage() { const message = getRandomInitiationMessage(); if (!message) return null; const initiationDiv = document.createElement('div'); initiationDiv.id = config.initiationMessageId; initiationDiv.innerHTML = ` `; // Store the full message for typewriting effect initiationDiv.dataset.fullMessage = message; return initiationDiv; } // Typewriting effect for initiation message function typeWriteMessage(element, text, speed = 50) { const textElement = element.querySelector('.initiation-message-text'); if (!textElement) return; let index = 0; const typeInterval = setInterval(() => { if (index < text.length) { textElement.textContent += text.charAt(index); index++; } else { clearInterval(typeInterval); // Remove cursor after typing is complete textElement.classList.add('typing-complete'); element.typeInterval = null; } }, speed); // Store interval ID to clear it if needed element.typeInterval = typeInterval; } // Show initiation message with animation function showInitiationMessage(initiationDiv, toggleChatFn) { if (!initiationDiv || !shouldShowInitiation()) return; document.body.appendChild(initiationDiv); // Show after a short delay for better UX setTimeout(() => { initiationDiv.classList.add('show'); // Start typewriting effect after bubble appears setTimeout(() => { const fullMessage = initiationDiv.dataset.fullMessage; if (fullMessage) { typeWriteMessage(initiationDiv, fullMessage, 40); } }, 300); }, 1500); // Click on message opens chat initiationDiv.addEventListener('click', (e) => { if (!e.target.classList.contains('initiation-close')) { // Clear typewriting interval if still running if (initiationDiv.typeInterval) { clearInterval(initiationDiv.typeInterval); } hideInitiationMessage(initiationDiv); toggleChatFn(); markInitiationShown(); } }); // Close button hides message const closeBtn = initiationDiv.querySelector('.initiation-close'); if (closeBtn) { closeBtn.addEventListener('click', (e) => { e.stopPropagation(); // Clear typewriting interval if still running if (initiationDiv.typeInterval) { clearInterval(initiationDiv.typeInterval); } hideInitiationMessage(initiationDiv); markInitiationShown(); }); } } // Hide initiation message function hideInitiationMessage(initiationDiv) { if (!initiationDiv) return; initiationDiv.classList.remove('show'); setTimeout(() => { if (initiationDiv.parentNode) { initiationDiv.parentNode.removeChild(initiationDiv); } }, 400); } // Initialize function to create and append elements function initialize() { isDestroyed = false updateStyles() // Create chat button with icon const button = document.createElement('div') button.id = config.buttonId button.classList.add('replypier-pending') button.innerHTML = `