Files
replypier/replypier-test/index.html
WangShen007 0cc6299d1c feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
2026-09-25 20:50:00 +08:00

769 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReplyPier Widget Test</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #ffffff;
color: #333;
}
h1 {
color: #333;
border-bottom: 2px solid #f34611;
padding-bottom: 10px;
font-size: clamp(1.5rem, 4vw, 2.5rem);
margin: 0 0 20px 0;
}
h2 {
color: #444;
font-size: clamp(1.25rem, 3vw, 1.5rem);
margin: 20px 0 15px 0;
}
.content {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
word-break: break-word;
}
pre {
overflow-x: auto;
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
margin: 15px 0;
}
pre code {
background: none;
padding: 0;
font-size: 0.85em;
white-space: pre;
}
.demo-section {
margin: 40px 0;
}
.widget-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 15px;
}
.widget-input {
flex: 1;
min-width: 200px;
padding: 12px;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 16px;
transition: border-color 0.3s ease;
}
.widget-input:focus {
outline: none;
border-color: #f34611;
box-shadow: 0 0 0 2px rgba(243, 70, 17, 0.1);
}
.demo-button {
background: #f34611;
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
min-height: 44px;
}
.demo-button:hover {
background: #d93d0c;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(243, 70, 17, 0.3);
}
.demo-button:active {
transform: translateY(0);
}
.demo-button.clear-button {
background: #dc3545;
}
.demo-button.clear-button:hover {
background: #c82333;
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
}
.demo-button.auth-button {
background: #28a745;
}
.demo-button.auth-button:hover {
background: #218838;
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
}
.auth-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.auth-section h3 {
font-size: 1.1rem;
color: #555;
margin: 0 0 10px 0;
}
.auth-section p {
font-size: 0.9rem;
color: #666;
margin: 0 0 15px 0;
}
.auth-status {
font-size: 0.9rem;
padding: 8px 12px;
border-radius: 4px;
display: inline-block;
}
.auth-status.success {
background: #d4edda;
color: #155724;
}
.auth-status.error {
background: #f8d7da;
color: #721c24;
}
.auth-status.loading {
background: #fff3cd;
color: #856404;
}
/* Responsive styles */
@media (max-width: 768px) {
body {
padding: 15px;
}
.content {
padding: 15px;
margin: 15px 0;
}
.demo-section {
margin: 30px 0;
}
.widget-controls {
flex-direction: column;
align-items: stretch;
}
.widget-input {
min-width: 100%;
margin-bottom: 10px;
}
.demo-button {
width: 100%;
margin: 5px 0;
}
pre {
padding: 10px;
font-size: 0.8em;
}
h1 {
text-align: center;
}
}
@media (max-width: 480px) {
body {
padding: 10px;
}
.content {
padding: 12px;
}
.demo-button {
padding: 14px 16px;
font-size: 16px;
}
.widget-input {
padding: 14px 12px;
font-size: 16px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
h1, h2 {
color: #f0f0f0;
}
.content {
background: #2d2d2d;
color: #e0e0e0;
}
code, pre {
background: #333;
color: #e0e0e0;
}
.widget-input {
background: #333;
border-color: #555;
color: #e0e0e0;
}
.widget-input:focus {
border-color: #f34611;
}
.auth-section {
border-top-color: #444;
}
.auth-section h3 {
color: #ccc;
}
.auth-section p {
color: #aaa;
}
.auth-status.success {
background: #1e4620;
color: #8fdf94;
}
.auth-status.error {
background: #4a1c1c;
color: #f5a5a5;
}
.auth-status.loading {
background: #4a3c00;
color: #ffd966;
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<h1>ReplyPier Widget Test Page</h1>
<div class="content">
<h2>About ReplyPier</h2>
<p>
This is a test page demonstrating the ReplyPier chat widget integration.
The widget provides real-time chat functionality with AI-powered responses
and seamless human handoff capabilities.
</p>
</div>
<div class="demo-section">
<h2>Widget Controls</h2>
<p>Enter your widget ID and click initialize to load the chat widget.</p>
<div class="widget-controls">
<input
type="text"
id="widgetIdInput"
placeholder="Enter Widget ID"
value=""
class="widget-input"
>
<button class="demo-button" id="widgetButton" onclick="initializeWidget()">
Initialize Widget
</button>
<button class="demo-button clear-button" id="clearButton" onclick="clearWidget()">
Clear Widget
</button>
</div>
<!-- API Key Authentication Section -->
<div class="auth-section">
<h3>Token Authentication (Optional)</h3>
<p>For agents with "Require Token Auth" enabled, provide an API key to authenticate.</p>
<div class="widget-controls">
<input
type="password"
id="apiKeyInput"
placeholder="Enter API Key (from Widget Apps)"
class="widget-input"
>
<input
type="email"
id="customerEmailInput"
placeholder="Customer Email (optional)"
class="widget-input"
>
</div>
<div class="widget-controls" style="margin-top: 10px;">
<button class="demo-button auth-button" id="authButton" onclick="authenticateAndInitialize()">
Authenticate & Initialize
</button>
<span id="authStatus" class="auth-status"></span>
</div>
</div>
</div>
<div class="content">
<h2>Integration Code</h2>
<p>To add the ReplyPier widget to your website, add the following code before the closing
<code>&lt;/body&gt;</code> tag:
</p>
<h3 style="margin-top: 20px; font-size: 1rem;">Basic (No Authentication)</h3>
<pre><code>
&lt;script&gt;
window.replypierId = 'YOUR_WIDGET_ID';
// Required for local testing — without it the widget uses the API URL baked
// into the build (the cloud API for a production build).
window.replypierBaseUrl = 'http://localhost:8000/api/v1';
&lt;/script&gt;
&lt;script src="http://localhost:5173/webclient/replypier.min.js"&gt;&lt;/script&gt;
</code></pre>
<h3 style="margin-top: 20px; font-size: 1rem;">With Token Authentication</h3>
<p style="font-size: 0.9em; color: #666;">For agents with "Require Token Auth" enabled, generate a token server-side:</p>
<pre><code>
// Server-side: Generate token using your API key
const response = await fetch('http://localhost:8000/api/v1/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
widget_id: 'YOUR_WIDGET_ID',
customer_email: 'user@example.com', // optional
ttl_seconds: 3600
})
});
const { data } = await response.json();
// Pass data.token to frontend
// Client-side: Initialize widget with token
&lt;script&gt;
window.replypierId = 'YOUR_WIDGET_ID';
window.replypierToken = 'TOKEN_FROM_SERVER';
window.replypierBaseUrl = 'http://localhost:8000/api/v1';
&lt;/script&gt;
&lt;script src="http://localhost:5173/webclient/replypier.min.js"&gt;&lt;/script&gt;
</code></pre>
</div>
<!-- ReplyPier Widget Integration -->
<script>
// Constants for localStorage keys
const WIDGET_ID_KEY = 'replypier_widget_id';
const WIDGET_INITIALIZED_KEY = 'replypier_initialized';
// Where the widget talks to. WITHOUT this the loader falls back to the URL
// baked in at build time — which for a :prod build is the cloud API, so a
// local test would silently chat with api.replypier.site.
// Override in the console: localStorage.setItem('replypier_api_base', '...')
const CM_API_BASE = localStorage.getItem('replypier_api_base') || 'http://localhost:8000/api/v1';
// The loader is served by the FRONTEND (vite in dev, the dashboard origin in
// production) — never copied into this folder, where it would go stale.
// The backend does not serve it. Rebuild after loader changes:
// cd ../frontend && npm run build:webclient:prod
// Follow the page's own scheme rather than hardcoding http://: served over
// https this page would otherwise pull executable code over an unencrypted
// connection, which the browser blocks as mixed content anyway.
const CM_LOADER_URL = localStorage.getItem('replypier_loader_url')
|| `${window.location.protocol}//localhost:5173/webclient/replypier.min.js`;
// Load saved widget ID and initialize on page load
document.addEventListener('DOMContentLoaded', function() {
loadSavedWidgetId();
autoInitializeWidget();
});
function loadSavedWidgetId() {
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
const widgetInput = document.getElementById('widgetIdInput');
const widgetButton = document.getElementById('widgetButton');
if (savedWidgetId) {
widgetInput.value = savedWidgetId;
widgetButton.textContent = 'Re-initialize Widget';
updateButtonStates(true);
} else {
// Set default widget ID if none saved
widgetInput.value = 'd63483f1-3954-4a94-a3f7-bb5039dda633';
updateButtonStates(false);
}
}
function autoInitializeWidget() {
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
const wasInitialized = localStorage.getItem(WIDGET_INITIALIZED_KEY) === 'true';
const hasApiKey = localStorage.getItem('replypier_api_key');
// Don't auto-initialize if we have an API key saved (requires token auth)
if (hasApiKey) {
console.log('Skipping auto-initialize: API key detected, use Authenticate & Initialize button');
return;
}
if (savedWidgetId && wasInitialized) {
console.log('Auto-initializing widget with saved ID:', savedWidgetId);
performWidgetInitialization(savedWidgetId);
}
}
function initializeWidget() {
const widgetId = document.getElementById('widgetIdInput').value;
if (!widgetId) {
alert('Please enter a widget ID');
return;
}
// Save widget ID to localStorage
localStorage.setItem(WIDGET_ID_KEY, widgetId);
localStorage.setItem(WIDGET_INITIALIZED_KEY, 'true');
performWidgetInitialization(widgetId);
updateButtonStates(true);
}
function performWidgetInitialization(widgetId) {
// Set the widget ID
window.replypierId = widgetId;
window.replypierBaseUrl = CM_API_BASE;
// Remove existing script if any
const existingScript = document.getElementById('replypier-script');
if (existingScript) {
existingScript.remove();
}
// Wait a moment for cleanup
setTimeout(() => {
// Create and add new script
const script = document.createElement('script');
script.id = 'replypier-script';
script.src = CM_LOADER_URL;
script.onload = function() {
// Initialize the widget once script loads
if (window.ReplyPier) {
window.ReplyPier.init({ id: widgetId });
console.log('Widget initialized with ID:', widgetId);
}
};
document.body.appendChild(script);
}, 100);
}
function clearWidget() {
// Remove widget script first to prevent it from recreating storage items
const existingScript = document.getElementById('replypier-script');
if (existingScript) {
existingScript.remove();
}
// Reset window properties to disable widget functionality
if (window.replypierId) {
delete window.replypierId;
}
if (window.ReplyPier) {
// Call destroy method if it exists
if (typeof window.ReplyPier.destroy === 'function') {
window.ReplyPier.destroy();
}
delete window.ReplyPier;
}
// Function to clear storage items with specific patterns
function clearStorageItems(storage, storageType) {
const keysToRemove = [];
// Collect all keys that match our patterns
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key && (
key.includes('replypier') ||
key.includes('widget') ||
key === 'rpid' ||
key.includes('chat') ||
key.startsWith('cm_') ||
key.includes('conversation') ||
key.includes('session')
)) {
keysToRemove.push(key);
}
}
// Remove all collected keys
keysToRemove.forEach(key => {
storage.removeItem(key);
console.log(`Removed ${storageType} key:`, key);
});
return keysToRemove.length;
}
// Clear localStorage
const localKeysRemoved = clearStorageItems(localStorage, 'localStorage');
// Clear sessionStorage
const sessionKeysRemoved = clearStorageItems(sessionStorage, 'sessionStorage');
// Also remove our specific keys (backup)
localStorage.removeItem(WIDGET_ID_KEY);
localStorage.removeItem(WIDGET_INITIALIZED_KEY);
localStorage.removeItem('rpid');
sessionStorage.removeItem('rpid');
// Clear any widget-related cookies
let cookiesCleared = 0;
document.cookie.split(";").forEach(function(c) {
const cookie = c.trim();
if (cookie.includes('replypier') ||
cookie.includes('widget') ||
cookie.includes('rpid') ||
cookie.includes('chat')) {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
cookiesCleared++;
console.log('Removed cookie:', name);
}
});
// Clear input field
document.getElementById('widgetIdInput').value = '';
// Force a small delay and check again for any recreated items
setTimeout(() => {
localStorage.removeItem('rpid');
sessionStorage.removeItem('rpid');
console.log('Final cleanup: Removed any remaining rpid keys');
}, 100);
updateButtonStates(false);
console.log(`Widget cleared completely:
- LocalStorage keys removed: ${localKeysRemoved}
- SessionStorage keys removed: ${sessionKeysRemoved}
- Cookies cleared: ${cookiesCleared}
- Widget script removed and window properties cleaned`);
}
function updateButtonStates(isInitialized) {
const widgetButton = document.getElementById('widgetButton');
const clearButton = document.getElementById('clearButton');
if (isInitialized) {
widgetButton.textContent = 'Re-initialize Widget';
clearButton.style.display = 'inline-block';
} else {
widgetButton.textContent = 'Initialize Widget';
clearButton.style.display = 'none';
}
}
// Update button state when input changes
document.addEventListener('DOMContentLoaded', function() {
const widgetInput = document.getElementById('widgetIdInput');
widgetInput.addEventListener('input', function() {
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
const currentValue = this.value;
const widgetButton = document.getElementById('widgetButton');
if (savedWidgetId && currentValue === savedWidgetId) {
widgetButton.textContent = 'Re-initialize Widget';
} else {
widgetButton.textContent = 'Initialize Widget';
}
});
// Load saved API key
const savedApiKey = localStorage.getItem('replypier_api_key');
if (savedApiKey) {
document.getElementById('apiKeyInput').value = savedApiKey;
}
});
// Authenticate with API key and initialize widget
async function authenticateAndInitialize() {
const widgetId = document.getElementById('widgetIdInput').value;
const apiKey = document.getElementById('apiKeyInput').value;
const customerEmail = document.getElementById('customerEmailInput').value;
const authStatus = document.getElementById('authStatus');
if (!widgetId) {
alert('Please enter a widget ID');
return;
}
if (!apiKey) {
alert('Please enter an API key');
return;
}
// Show loading state
authStatus.textContent = 'Authenticating...';
authStatus.className = 'auth-status loading';
try {
// Call the generate-token endpoint
const response = await fetch(`${CM_API_BASE}/generate-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
widget_id: widgetId,
customer_email: customerEmail || undefined,
ttl_seconds: 3600
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.data?.token) {
// Save API key for future use
localStorage.setItem('replypier_api_key', apiKey);
localStorage.setItem(WIDGET_ID_KEY, widgetId);
localStorage.setItem(WIDGET_INITIALIZED_KEY, 'true');
// Set the token for the widget
window.replypierToken = data.data.token;
window.replypierId = widgetId;
// Show success
authStatus.textContent = `Authenticated! Token expires: ${new Date(data.data.expires_at).toLocaleString()}`;
authStatus.className = 'auth-status success';
console.log('Token generated successfully:', {
widget_id: data.data.widget_id,
expires_at: data.data.expires_at,
expires_in: data.data.expires_in
});
// Initialize the widget with the token
performAuthenticatedWidgetInitialization(widgetId, data.data.token);
updateButtonStates(true);
} else {
throw new Error('Invalid response from server');
}
} catch (error) {
console.error('Authentication error:', error);
authStatus.textContent = `Error: ${error.message}`;
authStatus.className = 'auth-status error';
}
}
function performAuthenticatedWidgetInitialization(widgetId, token) {
// Set widget configuration - widget loader checks window.replypierToken first
window.replypierId = widgetId;
window.replypierToken = token;
window.replypierBaseUrl = CM_API_BASE;
// Remove existing script if any
const existingScript = document.getElementById('replypier-script');
if (existingScript) {
existingScript.remove();
}
// Also remove any existing widget elements to ensure fresh initialization
const existingContainer = document.getElementById('replypier-container');
const existingButton = document.getElementById('replypier-button');
const existingClose = document.getElementById('replypier-mobile-close');
const existingTopbar = document.getElementById('replypier-mobile-topbar');
if (existingContainer) existingContainer.remove();
if (existingButton) existingButton.remove();
if (existingClose) existingClose.remove();
if (existingTopbar) existingTopbar.remove();
// Clear ReplyPier instance
if (window.ReplyPier) {
delete window.ReplyPier;
}
// Wait a moment for cleanup
setTimeout(() => {
// Create and add new script
const script = document.createElement('script');
script.id = 'replypier-script';
script.src = CM_LOADER_URL;
script.onload = function() {
if (window.ReplyPier) {
window.ReplyPier.init({
id: widgetId
});
}
};
document.body.appendChild(script);
}, 200);
}
</script>
</body>
</html>