lthn.io/public/js/support-widget.js
Claude 77cc45dd83
feat: lthn.io CorePHP app — TLD website + blockchain services
Modules:
- Chain: daemon RPC client (DaemonRpc singleton, cached queries)
- Explorer: block browser, tx viewer, alias directory, search, stats API
- Names: .lthn TLD registrar portal (availability check, lookup, directory)
- Trade: scaffold (DEX frontend + API)
- Pool: scaffold (mining pool dashboard)

Replaces 5 Node.js containers (5.9GB) with one FrankenPHP app.
Built on CorePHP framework pattern from host.uk.com.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 16:13:55 +01:00

768 lines
28 KiB
JavaScript

/**
* SupportHost Chat Widget
*
* Embed this widget on any website to enable live chat with SupportHost.
*
* Usage:
* <script src="https://host.uk.com/js/support-widget.js"></script>
* <script>
* SupportWidget.init({ token: 'YOUR_WIDGET_TOKEN' });
* </script>
*/
(function() {
'use strict';
const SupportWidget = {
config: {
token: null,
baseUrl: null,
color: '#6366f1',
position: 'right',
greeting: 'Hello! How can we help you today?',
placeholder: 'Type a message...',
requireEmail: false,
},
state: {
isOpen: false,
isInitialised: false,
isLoading: false,
contactId: null,
visitorId: null,
conversationId: null,
messages: [],
widgetConfig: null,
},
container: null,
/**
* Initialise the widget.
*/
init(userConfig) {
if (this.state.isInitialised) {
console.warn('SupportWidget already initialised');
return;
}
if (!userConfig.token) {
console.error('SupportWidget: token is required');
return;
}
this.config.token = userConfig.token;
this.config.baseUrl = userConfig.baseUrl || this.detectBaseUrl();
// Generate or restore visitor ID
this.state.visitorId = this.getVisitorId();
this.createWidget();
this.bindEvents();
this.initWithServer();
},
/**
* Detect base URL from script src.
*/
detectBaseUrl() {
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts[i].src;
if (src.includes('support-widget.js')) {
const url = new URL(src);
return url.origin;
}
}
return window.location.origin;
},
/**
* Get or create visitor ID.
*/
getVisitorId() {
const key = 'support_widget_visitor_id';
let visitorId = localStorage.getItem(key);
if (!visitorId) {
visitorId = 'v_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem(key, visitorId);
}
return visitorId;
},
/**
* Create an element with attributes.
*/
createElement(tag, attrs, children) {
const el = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(key => {
if (key === 'className') {
el.className = attrs[key];
} else if (key === 'textContent') {
el.textContent = attrs[key];
} else {
el.setAttribute(key, attrs[key]);
}
});
}
if (children) {
children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child) {
el.appendChild(child);
}
});
}
return el;
},
/**
* Create SVG element.
*/
createSvg(path, size) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'currentColor');
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', path);
svg.appendChild(pathEl);
return svg;
},
/**
* Create widget DOM elements using safe DOM methods.
*/
createWidget() {
const widget = this.createElement('div', { id: 'support-widget' });
// Toggle button
const toggle = this.createElement('button', {
id: 'support-widget-toggle',
'aria-label': 'Open support chat'
});
toggle.appendChild(this.createSvg('M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z', '24'));
widget.appendChild(toggle);
// Panel
const panel = this.createElement('div', { id: 'support-widget-panel', className: 'sw-hidden' });
// Header
const header = this.createElement('div', { id: 'support-widget-header' });
header.appendChild(this.createElement('span', { id: 'support-widget-title', textContent: 'Support' }));
const closeBtn = this.createElement('button', {
id: 'support-widget-close',
'aria-label': 'Close chat',
textContent: '\u00D7'
});
header.appendChild(closeBtn);
panel.appendChild(header);
// Messages container
panel.appendChild(this.createElement('div', { id: 'support-widget-messages' }));
// Pre-chat form
const prechat = this.createElement('div', { id: 'support-widget-prechat', className: 'sw-hidden' });
prechat.appendChild(this.createElement('p', { id: 'support-widget-greeting' }));
const prechatForm = this.createElement('form', { id: 'support-widget-prechat-form' });
prechatForm.appendChild(this.createElement('input', {
type: 'email',
id: 'support-widget-email',
placeholder: 'Your email (optional)',
autocomplete: 'email'
}));
prechatForm.appendChild(this.createElement('input', {
type: 'text',
id: 'support-widget-name',
placeholder: 'Your name (optional)',
autocomplete: 'name'
}));
const textarea = this.createElement('textarea', {
id: 'support-widget-initial-message',
placeholder: 'How can we help?',
required: 'required',
rows: '3'
});
prechatForm.appendChild(textarea);
prechatForm.appendChild(this.createElement('button', {
type: 'submit',
id: 'support-widget-start-btn',
textContent: 'Start Chat'
}));
prechat.appendChild(prechatForm);
panel.appendChild(prechat);
// Chat form
const chatForm = this.createElement('form', { id: 'support-widget-form', className: 'sw-hidden' });
chatForm.appendChild(this.createElement('input', {
type: 'text',
id: 'support-widget-input',
placeholder: 'Type a message...',
autocomplete: 'off'
}));
const sendBtn = this.createElement('button', { type: 'submit', 'aria-label': 'Send message' });
sendBtn.appendChild(this.createSvg('M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', '20'));
chatForm.appendChild(sendBtn);
panel.appendChild(chatForm);
// Loading
const loading = this.createElement('div', { id: 'support-widget-loading', className: 'sw-hidden' });
loading.appendChild(this.createElement('span', { textContent: 'Connecting...' }));
panel.appendChild(loading);
widget.appendChild(panel);
document.body.appendChild(widget);
this.container = widget;
this.injectStyles();
},
/**
* Inject widget styles.
*/
injectStyles() {
const style = document.createElement('style');
style.textContent = `
#support-widget {
position: fixed;
bottom: 20px;
${this.config.position === 'left' ? 'left' : 'right'}: 20px;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
}
#support-widget * {
box-sizing: border-box;
}
#support-widget-toggle {
width: 56px;
height: 56px;
border-radius: 50%;
background: ${this.config.color};
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
#support-widget-toggle:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
#support-widget-panel {
position: absolute;
bottom: 70px;
${this.config.position === 'left' ? 'left' : 'right'}: 0;
width: 360px;
max-width: calc(100vw - 40px);
height: 500px;
max-height: calc(100vh - 100px);
background: white;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
#support-widget-panel.sw-hidden {
display: none;
}
#support-widget-header {
padding: 16px 20px;
background: ${this.config.color};
color: white;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
#support-widget-title {
font-weight: 600;
font-size: 16px;
}
#support-widget-close {
background: none;
border: none;
color: white;
font-size: 28px;
line-height: 1;
cursor: pointer;
padding: 0;
opacity: 0.8;
transition: opacity 0.2s;
}
#support-widget-close:hover {
opacity: 1;
}
#support-widget-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #f9fafb;
}
#support-widget-prechat {
flex: 1;
padding: 20px;
overflow-y: auto;
}
#support-widget-prechat.sw-hidden {
display: none;
}
#support-widget-greeting {
margin: 0 0 16px 0;
color: #374151;
}
#support-widget-prechat-form input,
#support-widget-prechat-form textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
margin-bottom: 12px;
font-size: 14px;
font-family: inherit;
}
#support-widget-prechat-form textarea {
resize: none;
}
#support-widget-prechat-form input:focus,
#support-widget-prechat-form textarea:focus {
outline: none;
border-color: ${this.config.color};
box-shadow: 0 0 0 3px ${this.config.color}20;
}
#support-widget-start-btn {
width: 100%;
padding: 12px;
background: ${this.config.color};
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
#support-widget-start-btn:hover {
opacity: 0.9;
}
#support-widget-start-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#support-widget-form {
display: flex;
padding: 12px;
border-top: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
}
#support-widget-form.sw-hidden {
display: none;
}
#support-widget-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 24px;
font-size: 14px;
font-family: inherit;
margin-right: 8px;
}
#support-widget-input:focus {
outline: none;
border-color: ${this.config.color};
}
#support-widget-form button {
width: 40px;
height: 40px;
padding: 0;
background: ${this.config.color};
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
flex-shrink: 0;
}
#support-widget-form button:hover {
opacity: 0.9;
}
#support-widget-loading {
padding: 20px;
text-align: center;
color: #6b7280;
}
#support-widget-loading.sw-hidden {
display: none;
}
.sw-message {
margin-bottom: 12px;
display: flex;
flex-direction: column;
}
.sw-message.sw-customer {
align-items: flex-end;
}
.sw-message.sw-agent {
align-items: flex-start;
}
.sw-message-bubble {
display: inline-block;
padding: 10px 14px;
border-radius: 16px;
max-width: 85%;
word-wrap: break-word;
}
.sw-customer .sw-message-bubble {
background: ${this.config.color};
color: white;
border-bottom-right-radius: 4px;
}
.sw-agent .sw-message-bubble {
background: white;
color: #1f2937;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.sw-message-meta {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.sw-agent-name {
font-weight: 500;
color: #6b7280;
font-size: 12px;
margin-bottom: 4px;
}
`;
document.head.appendChild(style);
},
/**
* Bind event listeners.
*/
bindEvents() {
document.getElementById('support-widget-toggle').addEventListener('click', () => this.toggle());
document.getElementById('support-widget-close').addEventListener('click', () => this.close());
document.getElementById('support-widget-prechat-form').addEventListener('submit', (e) => {
e.preventDefault();
this.startChat();
});
document.getElementById('support-widget-form').addEventListener('submit', (e) => {
e.preventDefault();
this.sendMessage();
});
},
/**
* Initialise with server.
*/
async initWithServer() {
this.showLoading(true);
try {
const response = await fetch(`${this.config.baseUrl}/api/support/chat/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token: this.config.token,
visitor_id: this.state.visitorId,
page_url: window.location.href,
page_title: document.title,
}),
});
const data = await response.json();
if (!response.ok) {
console.error('SupportWidget init failed:', data.message || data.error);
this.showError(data.message || 'Failed to connect');
return;
}
this.state.contactId = data.contact.id;
this.state.widgetConfig = data.widget;
this.state.isInitialised = true;
// Apply server configuration
if (data.widget.name) {
document.getElementById('support-widget-title').textContent = data.widget.name;
}
if (data.widget.greeting) {
document.getElementById('support-widget-greeting').textContent = data.widget.greeting;
}
if (data.widget.placeholder) {
document.getElementById('support-widget-input').placeholder = data.widget.placeholder;
}
if (data.widget.require_email) {
const emailInput = document.getElementById('support-widget-email');
emailInput.required = true;
emailInput.placeholder = 'Your email (required)';
}
// Check for active conversation
if (data.active_conversation) {
this.state.conversationId = data.active_conversation.id;
await this.loadHistory();
this.showChatView();
} else {
this.showPrechatView();
}
} catch (err) {
console.error('SupportWidget init error:', err);
this.showError('Failed to connect');
}
this.showLoading(false);
},
/**
* Toggle widget open/closed.
*/
toggle() {
this.state.isOpen = !this.state.isOpen;
document.getElementById('support-widget-panel').classList.toggle('sw-hidden', !this.state.isOpen);
},
/**
* Close widget.
*/
close() {
this.state.isOpen = false;
document.getElementById('support-widget-panel').classList.add('sw-hidden');
},
/**
* Show loading state.
*/
showLoading(show) {
document.getElementById('support-widget-loading').classList.toggle('sw-hidden', !show);
document.getElementById('support-widget-messages').classList.toggle('sw-hidden', show);
document.getElementById('support-widget-prechat').classList.add('sw-hidden');
document.getElementById('support-widget-form').classList.add('sw-hidden');
},
/**
* Show pre-chat form.
*/
showPrechatView() {
document.getElementById('support-widget-loading').classList.add('sw-hidden');
document.getElementById('support-widget-messages').classList.add('sw-hidden');
document.getElementById('support-widget-prechat').classList.remove('sw-hidden');
document.getElementById('support-widget-form').classList.add('sw-hidden');
},
/**
* Show chat view.
*/
showChatView() {
document.getElementById('support-widget-loading').classList.add('sw-hidden');
document.getElementById('support-widget-messages').classList.remove('sw-hidden');
document.getElementById('support-widget-prechat').classList.add('sw-hidden');
document.getElementById('support-widget-form').classList.remove('sw-hidden');
},
/**
* Show error message.
*/
showError(message) {
const loading = document.getElementById('support-widget-loading');
// Clear existing content safely
while (loading.firstChild) {
loading.removeChild(loading.firstChild);
}
const span = document.createElement('span');
span.style.color = '#ef4444';
span.textContent = message;
loading.appendChild(span);
loading.classList.remove('sw-hidden');
},
/**
* Start a new chat.
*/
async startChat() {
const email = document.getElementById('support-widget-email').value.trim();
const name = document.getElementById('support-widget-name').value.trim();
const message = document.getElementById('support-widget-initial-message').value.trim();
if (!message) return;
const btn = document.getElementById('support-widget-start-btn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const response = await fetch(`${this.config.baseUrl}/api/support/chat/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token: this.config.token,
contact_id: this.state.contactId,
email: email || null,
name: name || null,
message: message,
}),
});
const data = await response.json();
if (!response.ok) {
alert(data.message || 'Failed to start chat');
btn.disabled = false;
btn.textContent = 'Start Chat';
return;
}
this.state.conversationId = data.conversation.id;
this.addMessage(message, 'customer');
this.showChatView();
// Clear form
document.getElementById('support-widget-email').value = '';
document.getElementById('support-widget-name').value = '';
document.getElementById('support-widget-initial-message').value = '';
} catch (err) {
console.error('Start chat error:', err);
alert('Failed to start chat');
}
btn.disabled = false;
btn.textContent = 'Start Chat';
},
/**
* Send a message.
*/
async sendMessage() {
const input = document.getElementById('support-widget-input');
const message = input.value.trim();
if (!message || !this.state.conversationId) return;
this.addMessage(message, 'customer');
input.value = '';
try {
const response = await fetch(`${this.config.baseUrl}/api/support/chat/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token: this.config.token,
contact_id: this.state.contactId,
conversation_id: this.state.conversationId,
message: message,
}),
});
if (!response.ok) {
console.error('Send message failed');
}
} catch (err) {
console.error('Send message error:', err);
}
},
/**
* Load chat history.
*/
async loadHistory() {
try {
const response = await fetch(
`${this.config.baseUrl}/api/support/chat/history?` +
`token=${encodeURIComponent(this.config.token)}` +
`&contact_id=${this.state.contactId}` +
`&conversation_id=${this.state.conversationId}`,
{
headers: {
'Accept': 'application/json',
},
}
);
const data = await response.json();
if (response.ok && data.messages) {
const container = document.getElementById('support-widget-messages');
// Clear existing messages safely
while (container.firstChild) {
container.removeChild(container.firstChild);
}
data.messages.forEach(msg => {
this.addMessage(msg.body, msg.type, msg.agent_name, msg.created_at);
});
}
} catch (err) {
console.error('Load history error:', err);
}
},
/**
* Add message to chat using safe DOM methods.
*/
addMessage(text, type, agentName, timestamp) {
const container = document.getElementById('support-widget-messages');
const div = document.createElement('div');
div.className = `sw-message sw-${type}`;
// Agent name (if agent message)
if (type === 'agent' && agentName) {
const nameSpan = document.createElement('span');
nameSpan.className = 'sw-agent-name';
nameSpan.textContent = agentName;
div.appendChild(nameSpan);
}
// Message bubble
const bubble = document.createElement('div');
bubble.className = 'sw-message-bubble';
bubble.textContent = text;
div.appendChild(bubble);
// Timestamp
if (timestamp) {
const meta = document.createElement('span');
meta.className = 'sw-message-meta';
meta.textContent = this.formatTime(timestamp);
div.appendChild(meta);
}
container.appendChild(div);
container.scrollTop = container.scrollHeight;
this.state.messages.push({ text, type, timestamp });
},
/**
* Format timestamp.
*/
formatTime(isoString) {
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},
};
// Expose globally
window.SupportWidget = SupportWidget;
})();