Files
replypier/frontend/src/webclient/replypier.js
T

2376 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <body> 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 24px 0;
max-width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.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 {
font-size: 13px;
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 = `
<span class="initiation-orb"></span>
<button class="initiation-close" aria-label="关闭"></button>
<p class="initiation-message-text"></p>
`;
// 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 = `
<span class="rp-ring"></span>
<span class="rp-ring r2"></span>
<div class="rp-dots"><span class="rp-dot"></span><span class="rp-dot"></span><span class="rp-dot"></span></div>
<span class="rp-chevron">&#8964;</span>
<span class="rp-search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.35-4.35"></path></svg>
<span class="rp-search-placeholder"></span>
</span>
<span class="rp-badge"></span>
`
// Safety net: reveal anyway if nothing else does within a few seconds (e.g. an
// unexpected error before the fetch's own error handling runs), so the launcher
// is never left permanently invisible.
revealTimeoutId = setTimeout(revealButton, 8000)
// Create chat container
const container = document.createElement('div')
container.id = config.containerId
// Create mobile minimize button
const mobileCloseButton = document.createElement('div')
mobileCloseButton.id = 'replypier-mobile-close'
mobileCloseButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
// Create mobile topbar for ASK_ANYTHING style
const mobileTopbar = document.createElement('div')
mobileTopbar.id = 'replypier-mobile-topbar'
mobileTopbar.innerHTML = `
<h3 class="topbar-title">聊天</h3>
<div style="width: 44px;"></div>
`
// Dimmed backdrop behind the centered Ask AI palette. Always in the DOM but only
// visible in that mode (see updateStyles); clicking it closes, as users expect.
const backdrop = document.createElement('div')
backdrop.id = config.backdropId
// Add elements to document
document.body.appendChild(button)
document.body.appendChild(backdrop)
document.body.appendChild(container)
document.body.appendChild(mobileCloseButton)
document.body.appendChild(mobileTopbar)
updateBadge()
// The button just entered the DOM — populate its text content (search placeholder).
syncLauncherContent()
let isOpen = false
let iframe = null
let isLoading = false
let initiationMessageElement = null
function toggleChat() {
isOpen = !isOpen
container.classList.toggle('active')
backdrop.classList.toggle('active', isOpen)
button.classList.toggle('active')
mobileCloseButton.classList.toggle('active')
if (isOpen) {
config.hasOpened = true
config.unreadCount = 0
}
// Tell the iframe whether it's visible so it can track unread messages.
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: 'WIDGET_VISIBILITY', open: isOpen }, '*')
}
updateBadge()
// Hide initiation message when chat is opened
if (isOpen && initiationMessageElement) {
hideInitiationMessage(initiationMessageElement);
initiationMessageElement = null;
markInitiationShown();
}
// Handle mobile button visibility
if (isMobileDevice()) {
if (isOpen) {
// When opening on mobile, hide the button
button.classList.remove('mobile-closed')
// Stop bouncing when open
button.style.animation = ''
} else {
// When closing on mobile, show the button
button.classList.add('mobile-closed')
// Add subtle idle bounce when closed
button.style.animation = 'replypier-float 4s ease-in-out infinite'
// Hide topbar when closing
mobileTopbar.classList.remove('active')
document.body.classList.remove('ask-anything-mobile')
}
} else {
// On desktop, ensure mobile close button is hidden when widget is open
if (isOpen) {
mobileCloseButton.classList.remove('active')
}
}
if (isOpen && iframe) {
iframe.contentWindow.postMessage({ type: 'SCROLL_TO_BOTTOM' }, '*')
}
emitEvent(isOpen ? 'open' : 'close')
}
// Prefill (don't send) the chat input, e.g. ReplyPier.open({ message: '...' }).
// Queued until the iframe has loaded so an early open({message}) isn't lost.
let pendingPrefill = null
let iframeReady = false
function prefillMessage(text) {
const clean = String(text || '').slice(0, 2000)
if (!clean) return
if (iframeReady && iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: 'PREFILL_MESSAGE', text: clean }, '*')
} else {
pendingPrefill = clean
}
}
// The visitor's identity is baked into the document the backend renders (the
// token lands in __INITIAL_DATA__), so it can't be changed in place: switching
// users means throwing this frame away and fetching a new one. Everything tied
// to the old frame is reset here.
let loadGeneration = 0
function teardownFrame() {
// Invalidates the callbacks of a load still in flight — see prefetchWidget.
loadGeneration++
// Clears the iframe and, after a failed load, the error card.
while (container.firstChild) container.removeChild(container.firstChild)
iframe = null
widgetFrameWindow = null
currentSessionId = null
iframeReady = false
widgetReadyFired = false
pendingPrefill = null
isLoading = false
button.classList.remove('loading')
paletteContentHeight = 0
applyPaletteHeight()
}
// Rebuild the widget for whatever token is current — the mechanism behind
// ReplyPier.identify() / logout() / reload().
function reloadWidget() {
// The conversation goes away with the frame; leaving the panel open would
// just show the visitor an empty box while the replacement loads.
if (isOpen) toggleChat()
teardownFrame()
config.unreadCount = 0
config.hasOpened = false
updateBadge()
prefetchWidget()
}
// Remove the widget from the page entirely: frame, launcher, styles, listeners
// and timers. Nothing of ours is left behind for a site that is unmounting the
// page region it lives in, or hiding chat for a whole class of visitor.
// ReplyPier.reload() builds it again from scratch.
function destroyWidget() {
if (isOpen) toggleChat()
teardownFrame()
window.removeEventListener('resize', handleResize)
window.removeEventListener('message', handleFrameMessage)
// The nudge is appended to <body>, not to our container, and it runs a
// typewriter interval that would keep ticking on a detached node.
if (initiationMessageElement) {
if (initiationMessageElement.typeInterval) clearInterval(initiationMessageElement.typeInterval)
removeNode(initiationMessageElement)
initiationMessageElement = null
}
;[button, backdrop, container, mobileCloseButton, mobileTopbar].forEach(removeNode)
removeNode(document.getElementById('replypier-styles'))
document.body.classList.remove('ask-anything-mobile')
// A rebuilt launcher starts hidden again (.replypier-pending) and needs its
// own reveal, so drop the reveal state and the pending safety-net timer with it.
buttonRevealed = false
if (revealTimeoutId) {
clearTimeout(revealTimeoutId)
revealTimeoutId = null
}
onRevealCallbacks.length = 0
config.unreadCount = 0
config.hasOpened = false
controller = null
pendingCalls.length = 0
triggerClickQueued = false
isDestroyed = true
}
// Create error UI for authentication failures using safe DOM methods
function createErrorUI(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'replypier-error-ui';
const card = document.createElement('div');
card.className = 'replypier-error-card';
// Close button (top-right). Without this there's no way to dismiss the error on
// mobile, where the launcher is hidden while the widget is open.
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'replypier-error-close';
closeBtn.setAttribute('aria-label', '关闭聊天');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', function () {
if (isOpen) toggleChat();
});
// Anchored to the full-panel wrapper so it sits at the top-right of the screen
// when the error is shown full-screen on mobile.
errorDiv.appendChild(closeBtn);
// Icon wrapper
const iconWrapper = document.createElement('div');
iconWrapper.className = 'replypier-error-icon-wrapper';
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
iconSvg.setAttribute('class', 'replypier-error-icon');
iconSvg.setAttribute('viewBox', '0 0 24 24');
iconSvg.setAttribute('fill', 'none');
iconSvg.setAttribute('stroke', 'currentColor');
iconSvg.setAttribute('stroke-width', '1.5');
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute('d', 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z');
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path2.setAttribute('d', 'M9 12l2 2 4-4');
iconSvg.appendChild(path1);
iconSvg.appendChild(path2);
iconWrapper.appendChild(iconSvg);
// Title
const title = document.createElement('h2');
title.className = 'replypier-error-title';
title.textContent = '客服暂不可用';
// Message
const messageEl = document.createElement('p');
messageEl.className = 'replypier-error-message';
messageEl.textContent = message;
// Footer
const footer = document.createElement('div');
footer.className = 'replypier-error-footer';
const logoSvg = document.createElement('span');
// Trusted bundled vector: the same source used by the dashboard and widget.
logoSvg.innerHTML = paperBoatSvg;
const mark = logoSvg.querySelector('svg');
mark.setAttribute('width', '20');
mark.setAttribute('height', '16');
mark.setAttribute('aria-hidden', 'true');
const footerText = document.createElement('a');
footerText.href = 'https://replypier.site';
footerText.target = '_blank';
footerText.rel = 'noopener noreferrer';
footerText.className = 'replypier-error-footer-link';
footerText.textContent = '由 ReplyPier 提供支持';
footer.appendChild(logoSvg);
footer.appendChild(footerText);
// Assemble card
card.appendChild(iconWrapper);
card.appendChild(title);
card.appendChild(messageEl);
card.appendChild(footer);
errorDiv.appendChild(card);
return errorDiv;
}
// Start prefetching the widget data
async function prefetchWidget() {
if (isLoading || iframe) return
// A reload for a new identity bumps loadGeneration, so a response that was
// already on its way for the previous one is dropped instead of rendered.
const generation = loadGeneration
try {
isLoading = true
button.classList.add('loading')
const token = getStoredToken();
// Fetch widget data with Authorization header if token exists
const url = `${config.baseUrl}/widgets/${window.replypierId}/data?widget_id=${window.replypierId}`;
const options = {
method: 'GET',
mode: 'cors',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
};
fetch(url, options)
.then(async response => {
if (generation !== loadGeneration) return null;
// Handle 401 Unauthorized - only for token-required widgets
if (response.status === 401) {
// Try to read error detail to determine if this is token-auth required
try {
const errorData = await response.json();
const detail = errorData.detail;
// The stored token belongs to a visitor whose identity has run out —
// a page reopened long after the app identified them. Ask the page for
// a fresh one and load again as that visitor, rather than showing an
// error or silently starting over as a stranger.
if (detail && detail.code === 'identity_expired') {
button.classList.remove('loading')
emitEvent('identityExpired')
recoverIdentityAndLoad()
return null;
}
const errorDetail = typeof detail === 'string' ? detail : '';
// Only show auth error for token-required cases (contains generate-token or API key)
if (errorDetail.includes('generate-token') || errorDetail.includes('API key') || errorDetail.includes('Token must be obtained')) {
button.classList.remove('loading')
removeToken();
const errorUI = createErrorUI('此聊天组件尚未配置完成,请联系网站管理员开通在线客服。');
container.appendChild(errorUI);
// Reveal (with the default color) so this error is reachable —
// it'll never get a real color since the widget never loaded.
revealButton();
return null;
}
} catch {
// If we can't parse the response, fall through to default handling
}
// For non-token-auth 401s, let the iframe handle it (shouldn't normally happen)
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
return response.text();
})
.then(html => {
if (html === null) return; // Error was already handled
if (generation !== loadGeneration) return; // superseded by a reload
iframe = document.createElement('iframe')
iframe.className = 'replypier-iframe'
// Screen readers announce a frame by its title.
iframe.title = '聊天'
iframe.srcdoc = html;
iframe.addEventListener('load', function () {
iframeReady = true
// Developer display overrides, known at load. Dashboard defaults merge
// later; the CUSTOMIZATION_UPDATE reply re-sends the final values.
iframe.contentWindow.postMessage(widgetDisplayPayload(), '*')
// The widget is prefetched while still hidden — say so, or surfaces that
// focus themselves on open (the Ask AI palette) would grab focus now.
iframe.contentWindow.postMessage({ type: 'WIDGET_VISIBILITY', open: isOpen }, '*')
if (pendingPrefill) {
iframe.contentWindow.postMessage({ type: 'PREFILL_MESSAGE', text: pendingPrefill }, '*')
pendingPrefill = null
}
widgetReadyFired = true
// A frame that actually loaded clears the recovery budget.
identityRecoveryUsed = false
emitEvent('ready')
})
container.appendChild(iframe)
// contentWindow is a stable WindowProxy from the moment the element is in
// the DOM — capture now so even pre-load messages pass the source check.
widgetFrameWindow = iframe.contentWindow
button.classList.remove('loading')
iframe.style.opacity = '1'
})
.catch(error => {
if (generation !== loadGeneration) return; // superseded by a reload
console.error('Failed to load widget:', error)
button.classList.remove('loading')
// A failed fetch here is almost always the browser blocking the request
// because this domain isn't in the organization's allowed domains (CORS).
// Point the site owner at the fix instead of a dead-end "try again later".
const host = window.location.hostname || '当前域名';
const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/i.test(host) || /\.local$/i.test(host);
const message = isLocal
? '聊天窗口无法在“' + host + '”上加载。本地测试时,请在 ReplyPier 后台(组织设置)中把组织域名设为“' + host + '”。'
: '聊天窗口无法在“' + host + '”上加载。请在 ReplyPier 后台(组织设置)中将此域名加入组织,即可在此启用聊天。';
const errorUI = createErrorUI(message);
container.appendChild(errorUI);
// Reveal (with the default color) so this error is reachable — it'll
// never get a real color since the widget never loaded.
revealButton();
});
} catch (error) {
console.error('Failed to load widget:', error)
button.classList.remove('loading')
revealButton()
} finally {
isLoading = false
}
}
// Token + unread-count updates from the widget frame. Registered once for the
// page: the frame is replaced on every identity change (see reloadWidget), and
// registering per load would stack a duplicate handler each time.
function handleFrameMessage(event) {
if (!event.data || typeof event.data.type !== 'string') return
// Only the widget iframe may talk to the loader. Both frames can be
// null-origin (srcdoc), so identity of the source window is the check.
if (!widgetFrameWindow || event.source !== widgetFrameWindow) return
if (event.data.type === 'UNREAD_COUNT') {
config.unreadCount = Math.max(0, parseInt(event.data.count, 10) || 0)
updateBadge()
// With a hidden launcher there's no badge — the site listens for this
// event to decorate its own trigger.
emitEvent('unread', config.unreadCount)
return
}
if (event.data.type === 'TOKEN_UPDATE') {
const updated = normalizeToken(event.data.token)
if (!updated) return
saveToken(updated)
// Confirm token storage to iframe
event.source.postMessage({ type: 'TOKEN_RECEIVED', token: updated }, '*')
return
}
if (event.data.type === 'CHAT_SESSION') {
currentSessionId = String(event.data.sessionId || '') || null
// Only a conversation the server actually created is news: reconnecting into
// the one already in progress is not a new chat. `authenticated` says whether
// it still belongs to the identified visitor, so a site can re-identify
// instead of guessing.
if (event.data.created) {
emitEvent('chatCreated', {
sessionId: currentSessionId,
authenticated: !!event.data.authenticated,
})
}
return
}
if (event.data.type === 'IDENTITY_EXPIRED') {
// The widget cannot mint a token: that needs the site's API key. Ask the page
// for one, and tell it either way so it can react on its own terms.
emitEvent('identityExpired')
requestTokenFromHost()
}
return
}
// The frame could not even be fetched with the stored token. Replace the identity
// if the page can, and otherwise fall back to an anonymous visitor - a chat widget
// that refuses to load at all is worse than one that has forgotten who you are.
function recoverIdentityAndLoad() {
if (identityRecoveryUsed || typeof config.tokenProvider !== 'function') {
// Nobody can replace the identity, so load as an anonymous visitor rather
// than leaving the page with a chat widget that refuses to open at all.
removeToken()
window.replypierToken = null
prefetchWidget()
return
}
identityRecoveryUsed = true
Promise.resolve()
.then(() => config.tokenProvider())
.then((token) => {
const next = normalizeToken(token)
if (next) {
window.replypierToken = next
saveToken(next)
} else {
removeToken()
window.replypierToken = null
}
})
.catch((error) => {
console.error('ReplyPier: tokenProvider failed', error)
removeToken()
window.replypierToken = null
})
.finally(() => {
prefetchWidget()
})
}
// Ask the host page for a replacement token and hand it to the LIVE frame.
// Deliberately not a reload: the visitor is looking at a conversation, and
// rebuilding the frame would take it away to fix a problem they never saw.
let tokenRequestInFlight = false
// A tokenProvider that keeps handing back a token the backend rejects would
// otherwise fetch in a loop. One recovery per successful load is enough.
let identityRecoveryUsed = false
function requestTokenFromHost() {
if (tokenRequestInFlight) return
if (identityRecoveryUsed || typeof config.tokenProvider !== 'function') {
// Nothing is coming: let the frame stop waiting and carry on anonymously,
// rather than leaving the visitor at a chat that can't authenticate.
tellFrame({ type: 'IDENTITY_UNAVAILABLE' })
return
}
tokenRequestInFlight = true
identityRecoveryUsed = true
Promise.resolve()
.then(() => config.tokenProvider())
.then((token) => {
const next = normalizeToken(token)
if (!next) {
tellFrame({ type: 'IDENTITY_UNAVAILABLE' })
return
}
window.replypierToken = next
saveToken(next)
tellFrame({ type: 'TOKEN_REFRESH', token: next })
})
.catch((error) => {
console.error('ReplyPier: tokenProvider failed', error)
tellFrame({ type: 'IDENTITY_UNAVAILABLE' })
})
.finally(() => {
tokenRequestInFlight = false
})
}
function tellFrame(message) {
if (widgetFrameWindow) widgetFrameWindow.postMessage(message, '*')
}
window.addEventListener('message', handleFrameMessage)
// Start prefetching immediately
prefetchWidget()
// Show initiation message shortly after the launcher becomes visible — scheduling
// this from page-load time (rather than from reveal) could show a nudge bubble
// pointing at a launcher that isn't on screen yet.
onRevealCallbacks.push(() => {
setTimeout(() => {
if (isDestroyed) return
if (!isOpen && config.chatInitiationMessages.length > 0) {
initiationMessageElement = createInitiationMessage();
showInitiationMessage(initiationMessageElement, toggleChat);
}
}, 2000)
})
// Don't auto-open on mobile devices - let user initiate
// Mobile users will see the chat button and can tap to open
// Add click event listeners
button.addEventListener('click', toggleChat)
mobileCloseButton.addEventListener('click', toggleChat)
// Clicking the dimmed page closes the Ask AI palette.
backdrop.addEventListener('click', function () {
if (isOpen) toggleChat()
})
// Initialize mobile button visibility
if (isMobileDevice() && !isOpen) {
button.classList.add('mobile-closed')
// Idle bounce animation when initially closed
button.style.animation = 'replypier-float 4s ease-in-out infinite'
}
// Handle window resize to update mobile behavior
function handleResize() {
const isMobile = isMobileDevice()
if (isMobile && !isOpen) {
// On mobile when closed, show the button
button.classList.add('mobile-closed')
button.style.animation = 'replypier-float 4s ease-in-out infinite'
// Ensure mobile close button is hidden when widget is closed
mobileCloseButton.classList.remove('active')
} else if (!isMobile) {
// On desktop, remove mobile-specific classes
button.classList.remove('mobile-closed')
button.style.animation = ''
// Ensure mobile close button is hidden on desktop
if (isOpen) {
mobileCloseButton.classList.remove('active')
}
}
// Update styles to handle viewport changes
updateStyles()
// The palette's ceiling is 76vh, which just moved.
applyPaletteHeight()
}
window.addEventListener('resize', handleResize)
// Expose the open/close mechanics to the public API, then run anything the
// site called before the DOM was ready.
controller = {
isOpen: function () { return isOpen },
toggle: toggleChat,
open: function (opts) {
if (!isOpen) toggleChat()
if (opts && opts.message) prefillMessage(opts.message)
},
close: function () {
if (isOpen) toggleChat()
},
reload: reloadWidget,
destroy: destroyWidget,
}
pendingCalls.splice(0).forEach(function (fn) {
try { fn() } catch (e) { /* keep the widget alive if a queued call throws */ }
})
}
// Declarative options: the install snippet may set window.replypierConfig with the
// same shape as ReplyPier.init(options). Applied before the widget is built.
applyOptions(window.replypierConfig)
// Wait for DOM to be fully loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
if (!isDestroyed) initialize()
})
} else {
initialize()
}
// Keyboard: ⌘K / Ctrl+K opens the Ask AI palette, Esc closes it — the shortcuts this
// pattern trains people to expect. Only bound for the Ask AI styles, so we never
// hijack ⌘K on a site running the ordinary chat bubble.
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform || '')
document.addEventListener('keydown', function (e) {
if (!controller || !isAskAiModal()) return
// Accept only the platform's palette chord: Ctrl+K on macOS is the shell's
// kill-to-end-of-line inside text fields, and stealing it breaks typing.
const chord = isMac ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey)
if (config.hotkey && chord && !e.altKey && !e.repeat && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
controller.toggle()
return
}
if (e.key === 'Escape' && controller.isOpen()) {
controller.close()
}
})
// No-code custom launcher: any element matching the developer's `trigger` selector or
// carrying data-replypier-open toggles the chat. Delegated, so elements added after
// page load work too.
document.addEventListener('click', function (e) {
const el = e.target
if (!el || !el.closest) return
let match = el.closest('[data-replypier-open]')
if (!match && config.trigger) {
try {
match = el.closest(config.trigger)
} catch (err) { /* invalid selector supplied by the site — ignore */ }
}
if (!match) return
// Stop in-page anchors used as triggers (<a href="#">) from scrolling the page.
if (match.tagName === 'A') e.preventDefault()
if (controller) {
controller.toggle()
} else if (!isDestroyed && !triggerClickQueued) {
// Clicks landing before the widget is built coalesce into one "open" — replaying
// N raw toggles would flap the window open/closed on init.
triggerClickQueued = true
pendingCalls.push(function () { controller.open() })
}
})
// Add message listener for customization updates
window.addEventListener('message', function (event) {
if (!event.data || typeof event.data.type !== 'string') return
// Only the widget iframe may drive the loader (hide/restyle/close). Origin
// strings are useless here (srcdoc frames are null-origin) — check identity.
if (!widgetFrameWindow || event.source !== widgetFrameWindow) return
// The Ask AI palette reports how tall its content is, so the box hugs a short
// answer instead of standing at full height with dead space underneath. Only the
// palette does this; every other mode keeps its configured geometry.
if (event.data.type === 'WIDGET_RESIZE') {
if (typeof event.data.height !== 'number' || !isFinite(event.data.height)) return
paletteContentHeight = Math.round(event.data.height)
applyPaletteHeight()
return
}
// The widget switched surface (palette ⇄ chat panel). Re-render the geometry for
// the new one and drop any hugged height the palette had asked for.
if (event.data.type === 'WIDGET_SURFACE') {
const palette = !!event.data.palette
if (config.surfaceIsPalette === palette) return
config.surfaceIsPalette = palette
if (!palette) paletteContentHeight = 0
updateStyles()
applyPaletteHeight()
return
}
// Header chevron inside the widget asks to minimize.
if (event.data.type === 'WIDGET_MINIMIZE') {
withController(function () { controller.close() })
return
}
if (event.data.type === 'CUSTOMIZATION_UPDATE') {
const customData = event.data.data;
const newColor = customData.chat_bubble_color;
config.chatBubbleColor = isValidHexColor(newColor) ? newColor : config.chatBubbleColor;
if (typeof customData.chat_style === 'string') {
config.chatStyle = customData.chat_style;
}
// Store chat initiation messages
if (customData.chat_initiation_messages && Array.isArray(customData.chat_initiation_messages)) {
config.chatInitiationMessages = customData.chat_initiation_messages;
}
// Dashboard "Widget placement" defaults. Applied before revealButton() below,
// so the launcher first appears already in its final mode/position — no jump.
applyDashboardDisplay(customData.widget_display)
// ASK_ANYTHING sits a little lower than the other styles (desktop only).
// A per-style default, not an override: geometry the developer set via
// init()/replypierConfig (devOverrides) always wins.
if (!isMobileDevice()) {
if (!devOverrides.containerBottom) {
config.containerBottom = customData.chat_style === 'ASK_ANYTHING' ? 90 : 100;
}
} else {
// Handle mobile ASK_ANYTHING style. This handler is outside initialize(), so
// reach the open-state and topbar through the controller/DOM, not closures.
const chatOpen = controller ? controller.isOpen() : false
const topbar = document.getElementById('replypier-mobile-topbar')
if (customData.chat_style === 'ASK_ANYTHING' && chatOpen) {
document.body.classList.add('ask-anything-mobile')
if (topbar) topbar.classList.add('active')
} else {
document.body.classList.remove('ask-anything-mobile')
if (topbar) topbar.classList.remove('active')
}
}
// Mobile positioning is handled by CSS media queries and should not be affected
updateStyles()
// Re-inject styles recolors the launcher; refresh the nudge badge count.
updateBadge()
// The real color is applied above, so it's now safe to show the launcher.
revealButton()
// Tell the widget the final display geometry (developer overrides merged with
// dashboard defaults) so its interior sizing can adapt. Replied to the sending
// frame — this handler sits outside initialize() and has no iframe reference.
if (event.source && typeof event.source.postMessage === 'function') {
event.source.postMessage(widgetDisplayPayload(), '*')
}
}
})
function isNumber(value) {
return typeof value === 'number' && isFinite(value)
}
function removeNode(node) {
if (node && node.parentNode) node.parentNode.removeChild(node)
}
// Re-inject styles so an already-rendered widget updates immediately; before the
// first render this is a no-op (initialize() injects them).
function refreshStylesIfRendered() {
if (document.getElementById('replypier-styles')) {
updateStyles()
}
}
// Move the launcher (and, with it, the chat window) to a custom position.
// Accepts { side, bottom, offset } — side 'left'|'right', distances in pixels from
// the bottom and the chosen side edge. Legacy { bottom, right } still works
// (right === offset). Use this to lift the widget above a fixed bottom nav bar.
// No effect on mobile, where the window is full-screen.
function applyPosition(position, deferRefresh) {
if (!position || typeof position !== 'object') return
if (position.side === 'left' || position.side === 'right') {
setDevConfig('side', position.side)
}
if (isNumber(position.bottom)) {
setDevConfig('launcherBottom', Math.max(0, position.bottom))
}
const offset = isNumber(position.offset) ? position.offset : position.right
if (isNumber(offset)) {
setDevConfig('launcherRight', Math.max(0, offset))
}
// applyOptions refreshes once for the whole batch; skip the extra rebuild here.
if (!deferRefresh) refreshStylesIfRendered()
}
// Apply developer options — shared by ReplyPier.init(options) and the declarative
// window.replypierConfig. Every recognized key counts as a developer override, so
// dashboard defaults arriving later never fight it. Loose sanity clamps only; the
// page owner is configuring their own page.
function applyOptions(options) {
if (!options || typeof options !== 'object') return
if (options.baseUrl) {
config.baseUrl = options.baseUrl
}
if (options.id) {
window.replypierId = options.id
}
if (typeof options.tokenProvider === 'function') {
// Called when the widget needs a token the page alone can mint: on an expired
// identity, or when there is none yet. Must resolve to a /generate-token token.
config.tokenProvider = options.tokenProvider
}
if (typeof options.launcher === 'boolean') {
setDevConfig('launcher', options.launcher)
}
if (DISPLAY_MODES.indexOf(options.displayMode) !== -1) {
setDevConfig('displayMode', options.displayMode)
}
if (isNumber(options.width)) {
setDevConfig('containerWidth', Math.max(280, options.width))
sizeIsExplicit.width = true
}
if (isNumber(options.height)) {
setDevConfig('containerHeight', Math.max(400, options.height))
sizeIsExplicit.height = true
}
if (typeof options.hotkey === 'boolean') {
setDevConfig('hotkey', options.hotkey)
}
if (isNumber(options.sidebarWidth)) {
setDevConfig('sidebarWidth', Math.max(280, options.sidebarWidth))
}
if (isNumber(options.zIndex)) {
setDevConfig('zIndex', Math.max(1, Math.floor(options.zIndex)))
}
if (typeof options.trigger === 'string' && options.trigger) {
setDevConfig('trigger', options.trigger)
}
if (options.searchBar && typeof options.searchBar === 'object') {
if (typeof options.searchBar.placeholder === 'string' && options.searchBar.placeholder) {
setDevConfig('searchPlaceholder', options.searchBar.placeholder.slice(0, 80))
}
if (isNumber(options.searchBar.width)) {
setDevConfig('searchBarWidth', Math.max(160, options.searchBar.width))
}
}
if (options.position) {
applyPosition(options.position, true)
}
refreshStylesIfRendered()
}
// Identity control for single-page apps. The widget's identity is fixed when its
// frame loads, so a login or logout that never reloads the page has to reload the
// frame — otherwise the visitor keeps the identity they had when the script ran.
// Before the widget is built there's nothing to reload: initialize() will fetch
// with whatever token is current by then. A destroyed widget stays destroyed —
// only reload() brings it back.
function reloadWidgetIfRunning() {
if (controller) controller.reload()
}
// Close the conversation the widget is leaving behind, then rebuild the frame.
// Dropping the frame alone only takes the conversation off THIS page: the session
// stays open on the server, so identifying that visitor again would hand them back
// a transcript logout() told them was gone. Closed from here rather than from the
// frame, which is about to be destroyed and may not get its request out; keepalive
// lets it finish even if the page is being torn down at the same time.
function endConversationThenReload(token, sessionId) {
if (token && sessionId) {
const url = `${config.baseUrl}/widgets/${window.replypierId}/end-chat`
+ `?session_id=${encodeURIComponent(sessionId)}&reason=CUSTOMER_REQUEST`
try {
fetch(url, {
method: 'POST',
mode: 'cors',
keepalive: true,
headers: { 'Authorization': `Bearer ${token}` },
}).catch(() => { /* the widget is being rebuilt either way */ })
} catch (e) {
/* the widget is being rebuilt either way */
}
}
currentSessionId = null
reloadWidgetIfRunning()
}
// ReplyPier.reload(): rebuild the widget, and build it again after destroy().
function reloadOrRebuild() {
if (controller) {
controller.reload()
return
}
if (!isDestroyed) return
isDestroyed = false
// With the loader in <head> there may be no <body> to build into yet; clearing
// the flag is enough, because the pending DOMContentLoaded boot then runs.
if (document.readyState !== 'loading') initialize()
}
function destroyWidgetIfRunning() {
if (controller) {
controller.destroy()
return
}
// destroy() before the DOM was ready — a site deciding this visitor gets no
// chat at all. Cancel the pending build rather than let it run afterwards.
isDestroyed = true
pendingCalls.length = 0
triggerClickQueued = false
}
// Switch to the identity carried by `token` (from your backend's
// /api/v1/generate-token call) and start that visitor's conversation.
function setIdentity(token) {
const next = normalizeToken(token)
if (!next) return
const current = getStoredToken()
// Sites re-identify on every route change; re-tokenizing with the token the
// widget already runs on must not throw away a conversation in progress.
if (next === current) return
const sameVisitor = current && tokenSubject(next) && tokenSubject(next) === tokenSubject(current)
window.replypierToken = next
saveToken(next)
// A fresher token for the SAME visitor is not a new identity: hand it to the
// running frame instead of rebuilding, so re-authenticating never costs the
// visitor the chat they are in the middle of.
if (sameVisitor && widgetFrameWindow) {
widgetFrameWindow.postMessage({ type: 'TOKEN_REFRESH', token: next }, '*')
return
}
// A different visitor: the previous one's conversation must not be left open on
// the server, or re-identifying them later would replay it.
endConversationThenReload(current, currentSessionId)
}
// Drop the current identity and its conversation, back to anonymous. The next
// load gets a fresh anonymous token from the backend.
function clearIdentity() {
const previous = getStoredToken()
const previousSession = currentSessionId
window.replypierToken = null
removeToken()
endConversationThenReload(previous, previousSession)
}
function setLauncherVisible(visible) {
setDevConfig('launcher', !!visible)
refreshStylesIfRendered()
}
// Public widget API. Open/close/toggle work from any point after the script loads;
// calls that land before the DOM is ready are queued and run on init.
window.ReplyPier = {
// Configure, e.g. init({ id, launcher: false, position: { side: 'left', bottom: 24, offset: 24 } })
init: applyOptions,
// Runtime reposition, e.g. ReplyPier.setPosition({ bottom: 100, right: 24 })
// Wrapped so stray extra arguments never hit applyPosition's internal param.
setPosition: function (position) { applyPosition(position) },
// open() accepts an optional { message } to prefill (not send) the chat input.
open: function (opts) {
withController(function () { controller.open(opts) })
},
close: function () {
withController(function () { controller.close() })
},
toggle: function () {
withController(function () { controller.toggle() })
},
isOpen: function () {
return controller ? controller.isOpen() : false
},
showLauncher: function () { setLauncherVisible(true) },
hideLauncher: function () { setLauncherVisible(false) },
// SPA login: identify(token) switches the widget to that visitor.
identify: function (token) { setIdentity(token) },
// SPA logout: back to an anonymous visitor, previous conversation dropped.
logout: function () { clearIdentity() },
// Rebuild the widget with the current token (e.g. after a failed load, or to
// bring it back after destroy()).
reload: function () { reloadOrRebuild() },
// Remove the widget from the page: nothing of ours is left behind.
destroy: function () { destroyWidgetIfRunning() },
// Events: 'ready' (widget loaded), 'open', 'close', 'unread' (count payload),
// 'chatCreated' ({ sessionId, authenticated }), 'identityExpired' (the token this
// widget was identified with has run out - hand it a new one with identify()).
on: function (event, cb) {
if (typeof event !== 'string' || typeof cb !== 'function') return
// 'ready' already happened — call straight away so consent-gated / lazy site
// code doesn't miss it. It fires only once, so there's nothing to register.
if (event === 'ready' && widgetReadyFired) {
try { cb() } catch (e) { /* site listener error */ }
return
}
;(listeners[event] = listeners[event] || []).push(cb)
},
off: function (event, cb) {
const cbs = listeners[event]
if (!cbs) return
const i = cbs.indexOf(cb)
if (i !== -1) cbs.splice(i, 1)
},
}
})()