2026-10-22 17:44:36 +08:00
|
|
|
|
import paperBoatSvg from '../assets/logo.svg?raw';
|
|
|
|
|
|
|
2026-09-25 20:50:00 +08:00
|
|
|
|
// @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;
|
2026-10-28 20:01:18 +08:00
|
|
|
|
margin: 0 0 20px 0;
|
2026-09-25 20:50:00 +08:00
|
|
|
|
max-width: 280px;
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 20:01:18 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 20:50:00 +08:00
|
|
|
|
.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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 20:01:18 +08:00
|
|
|
|
// Keep technical failure details in the console, not in the visitor's UI.
|
|
|
|
|
|
function createErrorUI(message, onRetry) {
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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');
|
2026-10-28 20:01:18 +08:00
|
|
|
|
iconSvg.setAttribute('aria-hidden', 'true');
|
2026-09-25 20:50:00 +08:00
|
|
|
|
iconSvg.setAttribute('fill', 'none');
|
|
|
|
|
|
iconSvg.setAttribute('stroke', 'currentColor');
|
|
|
|
|
|
iconSvg.setAttribute('stroke-width', '1.5');
|
2026-10-28 20:01:18 +08:00
|
|
|
|
iconSvg.setAttribute('stroke-linecap', 'round');
|
|
|
|
|
|
iconSvg.setAttribute('stroke-linejoin', 'round');
|
2026-09-25 20:50:00 +08:00
|
|
|
|
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
2026-10-28 20:01:18 +08:00
|
|
|
|
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');
|
2026-09-25 20:50:00 +08:00
|
|
|
|
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
2026-10-28 20:01:18 +08:00
|
|
|
|
path2.setAttribute('d', 'M8 10h8M8 13h5');
|
2026-09-25 20:50:00 +08:00
|
|
|
|
iconSvg.appendChild(path1);
|
|
|
|
|
|
iconSvg.appendChild(path2);
|
|
|
|
|
|
iconWrapper.appendChild(iconSvg);
|
|
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
|
|
const title = document.createElement('h2');
|
|
|
|
|
|
title.className = 'replypier-error-title';
|
2026-10-28 20:01:18 +08:00
|
|
|
|
title.textContent = '客服暂时无法连接';
|
2026-09-25 20:50:00 +08:00
|
|
|
|
|
|
|
|
|
|
// Message
|
|
|
|
|
|
const messageEl = document.createElement('p');
|
|
|
|
|
|
messageEl.className = 'replypier-error-message';
|
|
|
|
|
|
messageEl.textContent = message;
|
|
|
|
|
|
|
2026-10-28 20:01:18 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-25 20:50:00 +08:00
|
|
|
|
// Footer
|
|
|
|
|
|
const footer = document.createElement('div');
|
|
|
|
|
|
footer.className = 'replypier-error-footer';
|
2026-10-22 17:44:36 +08:00
|
|
|
|
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');
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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);
|
2026-10-28 20:01:18 +08:00
|
|
|
|
card.appendChild(retryButton);
|
2026-09-25 20:50:00 +08:00
|
|
|
|
card.appendChild(footer);
|
|
|
|
|
|
errorDiv.appendChild(card);
|
|
|
|
|
|
|
|
|
|
|
|
return errorDiv;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 20:01:18 +08:00
|
|
|
|
function showLoadError(previousError) {
|
|
|
|
|
|
removeNode(previousError)
|
|
|
|
|
|
const errorUI = createErrorUI('请检查网络后重试,或稍后再来。', function () {
|
|
|
|
|
|
prefetchWidget(errorUI)
|
|
|
|
|
|
})
|
|
|
|
|
|
container.appendChild(errorUI)
|
|
|
|
|
|
revealButton()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 20:50:00 +08:00
|
|
|
|
// Start prefetching the widget data
|
2026-10-28 20:01:18 +08:00
|
|
|
|
async function prefetchWidget(previousError) {
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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')
|
2026-10-28 20:01:18 +08:00
|
|
|
|
removeNode(previousError)
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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();
|
2026-10-28 20:01:18 +08:00
|
|
|
|
console.error('Failed to load widget: visitor authentication required (HTTP 401); check the widget token configuration.')
|
|
|
|
|
|
showLoadError(previousError)
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-10-28 20:01:18 +08:00
|
|
|
|
removeNode(previousError)
|
2026-09-25 20:50:00 +08:00
|
|
|
|
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')
|
2026-10-28 20:01:18 +08:00
|
|
|
|
showLoadError(previousError)
|
2026-09-25 20:50:00 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
} 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)
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|