gigafibre-fsm/services/targo-hub/public/chat.html
louispaulb 320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

364 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Gigafibre</title>
<link rel="icon" type="image/png" href="/icons/favicon-128x128.png">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; -webkit-font-smoothing: antialiased; }
.chat-page { display: flex; flex-direction: column; height: 100vh; height: 100dvh; max-width: 600px; margin: 0 auto; background: #f8fafc; position: relative; }
.chat-loading, .chat-error { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; gap: 12px; }
.chat-error-icon { font-size: 48px; }
.spinner { width: 36px; height: 36px; border: 3px solid #e2e8f0; border-top-color: #4f46e5; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.chat-header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-bottom: 1px solid #e2e8f0; }
.chat-logo { width: 36px; height: 36px; border-radius: 8px; background: #4f46e5; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 14px; flex-shrink: 0; }
.chat-title { font-weight: 600; font-size: 1rem; }
.chat-sub { font-size: 0.8rem; color: #94a3b8; }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; }
.chat-msg { display: flex; }
.chat-msg-customer { justify-content: flex-end; }
.chat-msg-agent { justify-content: flex-start; }
.chat-msg-system { justify-content: center; }
.chat-bubble { max-width: 80%; padding: 10px 14px; border-radius: 16px; font-size: 0.92rem; line-height: 1.4; word-break: break-word; }
.chat-msg-customer .chat-bubble { background: #4f46e5; color: #fff; border-bottom-right-radius: 4px; }
.chat-msg-agent .chat-bubble { background: #fff; color: #1e293b; border: 1px solid #e2e8f0; border-bottom-left-radius: 4px; }
.chat-msg-system .chat-bubble { background: transparent; color: #94a3b8; font-size: 0.8rem; padding: 4px 8px; }
.chat-meta { font-size: 0.7rem; opacity: 0.6; margin-top: 4px; text-align: right; }
.chat-via { background: rgba(255,255,255,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.65rem; margin-right: 4px; }
.chat-msg-agent .chat-via { background: #f1f5f9; }
.chat-input-bar { display: flex; gap: 8px; padding: 10px 12px; background: #fff; border-top: 1px solid #e2e8f0; }
.chat-input { flex: 1; border: 1px solid #e2e8f0; border-radius: 20px; padding: 10px 16px; font-size: 0.92rem; outline: none; resize: none; font-family: inherit; min-height: 42px; max-height: 120px; }
.chat-input:focus { border-color: #4f46e5; }
.chat-send { width: 42px; height: 42px; border-radius: 50%; background: #4f46e5; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.2s; }
.chat-send:hover { background: #4338ca; }
.chat-send:disabled { background: #cbd5e1; cursor: default; }
.chat-send svg { width: 20px; height: 20px; }
.chat-closed { padding: 16px; text-align: center; color: #94a3b8; font-size: 0.9rem; border-top: 1px solid #e2e8f0; }
.chat-push-banner { position: absolute; bottom: 70px; left: 12px; right: 12px; background: #4f46e5; color: #fff; padding: 10px 14px; border-radius: 10px; display: flex; align-items: center; font-size: 0.85rem; cursor: pointer; box-shadow: 0 4px 12px rgba(79,70,229,0.3); gap: 8px; }
.chat-push-banner .close { margin-left: auto; background: none; border: none; color: #fff; cursor: pointer; font-size: 18px; padding: 0 4px; }
.chat-reconnect-banner { position: sticky; top: 0; z-index: 10; background: #fef3c7; color: #92400e; padding: 8px 14px; text-align: center; font-size: 0.82rem; border-bottom: 1px solid #fcd34d; cursor: pointer; }
.chat-reconnect-banner:hover { background: #fde68a; }
.chat-status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.chat-status-dot.online { background: #22c55e; }
.chat-status-dot.offline { background: #ef4444; }
.chat-status-dot.reconnecting { background: #f59e0b; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.typing-bubble { display: flex; gap: 4px; padding: 12px 16px !important; }
.typing-bubble .dot { width: 8px; height: 8px; background: #94a3b8; border-radius: 50%; animation: typing 1.4s infinite ease-in-out; }
.typing-bubble .dot:nth-child(2) { animation-delay: 0.2s; }
.typing-bubble .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-6px); } }
.chat-msg-agent .chat-meta .chat-via-ai { background: #ede9fe; color: #7c3aed; padding: 1px 5px; border-radius: 3px; font-size: 0.65rem; margin-right: 4px; }
</style>
</head>
<body>
<div id="app" class="chat-page">
<div class="chat-loading">
<div class="spinner"></div>
<div>Chargement...</div>
</div>
</div>
<script>
const HUB_URL = window.location.origin
const token = window.location.pathname.split('/c/')[1]?.split('/')[0]?.split('?')[0]
const app = document.getElementById('app')
let conv = null
let eventSource = null
let sseRetryDelay = 1000
let sseConnected = false
let lastActivityAt = Date.now()
if (!token) {
showError('Lien invalide')
} else {
loadConversation()
}
async function loadConversation () {
try {
const res = await fetch(`${HUB_URL}/conversations/${token}`)
if (!res.ok) { showError('Conversation introuvable'); return }
conv = await res.json()
render()
connectSSE()
checkPush()
setupVisibilityHandler()
} catch {
showError('Erreur de connexion')
}
}
// Keep session alive when tab is visible, reconnect when returning
function setupVisibilityHandler () {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
lastActivityAt = Date.now()
// Reconnect SSE if disconnected
if (!sseConnected && conv?.status === 'active') {
refreshAndReconnect()
}
}
})
}
async function refreshAndReconnect () {
try {
const res = await fetch(`${HUB_URL}/conversations/${token}`)
if (!res.ok) return
const fresh = await res.json()
// Merge any messages we missed
for (const msg of fresh.messages) {
if (!conv.messages.find(m => m.id === msg.id)) {
conv.messages.push(msg)
appendMessage(msg)
}
}
conv.status = fresh.status
if (conv.status === 'active') connectSSE()
} catch (e) {
console.error('Refresh error:', e)
}
}
function showError (msg) {
app.innerHTML = `<div class="chat-error"><div class="chat-error-icon">⚠</div><div style="font-size:1.1rem;font-weight:600">${msg}</div><div style="font-size:0.85rem">Ce lien a peut-être expiré ou est invalide.</div></div>`
}
function render () {
const msgs = conv.messages.map(m => `
<div class="chat-msg chat-msg-${m.from}">
<div class="chat-bubble">
<div>${escapeHtml(m.text)}</div>
<div class="chat-meta">${m.via === 'ai' ? '<span class="chat-via-ai">AI</span>' : m.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(m.ts)}</div>
</div>
</div>
`).join('')
const inputBar = conv.status === 'active'
? `<div class="chat-input-bar">
<textarea id="msgInput" class="chat-input" placeholder="Tapez votre message..." rows="1"></textarea>
<button id="sendBtn" class="chat-send" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
</button>
</div>`
: '<div class="chat-closed">✓ Cette conversation est terminée.</div>'
app.innerHTML = `
<div class="chat-header">
<div class="chat-logo">G</div>
<div>
<div class="chat-title"><span class="chat-status-dot online"></span>${escapeHtml(conv.subject || 'Gigafibre')}</div>
<div class="chat-sub">${escapeHtml(conv.customerName || '')}</div>
</div>
</div>
<div id="messages" class="chat-messages">
${msgs || '<div class="chat-empty"><div style="font-size:32px;color:#cbd5e1">💬</div><div>Commencez la conversation</div></div>'}
</div>
${inputBar}
`
scrollToBottom()
// Bind events
const input = document.getElementById('msgInput')
const btn = document.getElementById('sendBtn')
if (input && btn) {
input.addEventListener('input', () => {
btn.disabled = !input.value.trim()
input.style.height = 'auto'
input.style.height = Math.min(input.scrollHeight, 120) + 'px'
})
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
})
btn.addEventListener('click', sendMessage)
}
}
async function sendMessage () {
const input = document.getElementById('msgInput')
const text = input?.value?.trim()
if (!text) return
input.value = ''
input.style.height = 'auto'
document.getElementById('sendBtn').disabled = true
try {
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const data = await res.json()
if (data.message && !conv.messages.find(m => m.id === data.message.id)) {
conv.messages.push(data.message)
appendMessage(data.message)
}
} catch (e) {
console.error('Send error:', e)
}
}
function appendMessage (msg) {
const container = document.getElementById('messages')
if (!container) return
// Remove empty state
const empty = container.querySelector('.chat-empty')
if (empty) empty.remove()
const div = document.createElement('div')
div.className = `chat-msg chat-msg-${msg.from}`
div.innerHTML = `
<div class="chat-bubble">
<div>${escapeHtml(msg.text)}</div>
<div class="chat-meta">${msg.via === 'ai' ? '<span class="chat-via-ai">AI</span>' : msg.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(msg.ts)}</div>
</div>
`
container.appendChild(div)
scrollToBottom()
}
function showTyping (show) {
const container = document.getElementById('messages')
if (!container) return
let el = document.getElementById('typing-indicator')
if (show && !el) {
el = document.createElement('div')
el.id = 'typing-indicator'
el.className = 'chat-msg chat-msg-agent'
el.innerHTML = '<div class="chat-bubble typing-bubble"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>'
container.appendChild(el)
scrollToBottom()
} else if (!show && el) {
el.remove()
}
}
function connectSSE () {
if (eventSource) { eventSource.close(); eventSource = null }
sseConnected = false
eventSource = new EventSource(`${HUB_URL}/conversations/${token}/sse`)
eventSource.onopen = () => {
sseConnected = true
sseRetryDelay = 1000
updateStatusDot('online')
// Remove reconnect banner if present
const banner = document.getElementById('reconnect-banner')
if (banner) banner.remove()
}
eventSource.onerror = () => {
sseConnected = false
updateStatusDot('reconnecting')
eventSource.close()
eventSource = null
// Auto-reconnect with backoff (max 30s)
setTimeout(() => {
if (conv?.status === 'active') connectSSE()
}, sseRetryDelay)
sseRetryDelay = Math.min(sseRetryDelay * 2, 30000)
}
eventSource.addEventListener('conv-typing', (e) => {
try { const d = JSON.parse(e.data); showTyping(d.typing) } catch {}
})
eventSource.addEventListener('conv-message', (e) => {
showTyping(false)
lastActivityAt = Date.now()
try {
const data = JSON.parse(e.data)
const msg = data.message
if (!conv.messages.find(m => m.id === msg.id)) {
conv.messages.push(msg)
appendMessage(msg)
}
} catch {}
})
}
function updateStatusDot (state) {
const dot = document.querySelector('.chat-status-dot')
if (dot) { dot.className = 'chat-status-dot ' + state }
}
function checkPush () {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !conv.vapidPublicKey) return
if (Notification.permission === 'granted') { registerPush(); return }
if (Notification.permission === 'default') {
// Show banner after a short delay
setTimeout(() => {
const banner = document.createElement('div')
banner.className = 'chat-push-banner'
banner.innerHTML = '🔔 Activer les notifications pour ne pas manquer de réponse <button class="close">✕</button>'
banner.addEventListener('click', async (e) => {
if (e.target.classList.contains('close')) { banner.remove(); return }
const perm = await Notification.requestPermission()
banner.remove()
if (perm === 'granted') registerPush()
})
app.appendChild(banner)
}, 2000)
}
}
async function registerPush () {
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(conv.vapidPublicKey),
})
await fetch(`${HUB_URL}/conversations/${token}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
} catch (e) { console.error('Push registration failed:', e) }
}
function urlBase64ToUint8Array (base64) {
const padding = '='.repeat((4 - base64.length % 4) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)))
}
function scrollToBottom () {
const el = document.getElementById('messages')
if (el) el.scrollTop = el.scrollHeight
}
function escapeHtml (str) {
if (!str) return ''
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function formatTime (ts) {
if (!ts) return ''
const d = new Date(ts)
const now = new Date()
const time = d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
if (d.toDateString() === now.toDateString()) return time
return d.toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' }) + ' ' + time
}
</script>
</body>
</html>