2409 lines
92 KiB
JavaScript
2409 lines
92 KiB
JavaScript
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 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 = `
|
||
<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">⌄</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
|
||
}
|
||
|
||
// Keep technical failure details in the console, not in the visitor's UI.
|
||
function createErrorUI(message, onRetry) {
|
||
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('aria-hidden', 'true');
|
||
iconSvg.setAttribute('fill', 'none');
|
||
iconSvg.setAttribute('stroke', 'currentColor');
|
||
iconSvg.setAttribute('stroke-width', '1.5');
|
||
iconSvg.setAttribute('stroke-linecap', 'round');
|
||
iconSvg.setAttribute('stroke-linejoin', 'round');
|
||
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path1.setAttribute('d', 'M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-8l-4 3v-3H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z');
|
||
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path2.setAttribute('d', 'M8 10h8M8 13h5');
|
||
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;
|
||
|
||
const retryButton = document.createElement('button');
|
||
retryButton.type = 'button';
|
||
retryButton.className = 'replypier-error-retry';
|
||
retryButton.textContent = '重试';
|
||
retryButton.addEventListener('click', function () {
|
||
retryButton.disabled = true;
|
||
retryButton.textContent = '正在重试…';
|
||
onRetry();
|
||
});
|
||
|
||
// 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(retryButton);
|
||
card.appendChild(footer);
|
||
errorDiv.appendChild(card);
|
||
|
||
return errorDiv;
|
||
}
|
||
|
||
function showLoadError(previousError) {
|
||
removeNode(previousError)
|
||
const errorUI = createErrorUI('请检查网络后重试,或稍后再来。', function () {
|
||
prefetchWidget(errorUI)
|
||
})
|
||
container.appendChild(errorUI)
|
||
revealButton()
|
||
}
|
||
|
||
// Start prefetching the widget data
|
||
async function prefetchWidget(previousError) {
|
||
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')
|
||
removeNode(previousError)
|
||
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();
|
||
console.error('Failed to load widget: visitor authentication required (HTTP 401); check the widget token configuration.')
|
||
showLoadError(previousError)
|
||
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
|
||
|
||
removeNode(previousError)
|
||
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')
|
||
showLoadError(previousError)
|
||
});
|
||
|
||
} 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)
|
||
},
|
||
}
|
||
})()
|