- 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>
364 lines
16 KiB
HTML
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
|
|
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>
|