feat(go-crm): full auth, routing, middleware, and supporting infra
- Add auth handlers (signup, login, logout, account management) with bcrypt - Add client, customer, service, scheduling, payment, question, answer handlers - Add dashboard, monthly report, and lead pipeline pages - Add UTF-8 middleware to force charset on HTML responses - Add config package with env-based overrides for DB path, secrets, endpoints - Add parser package for WhatsApp message ingestion - Add clean-arch layers: pkg/domain, pkg/repo, pkg/usecase for leads - Add cmd/migrate utility for DB migrations - Add Makefile, README, run-tests.sh, and dev scripts - Update docker-compose.yml with memory limits - Update .air.toml to exclude DB files and stop on errors - Update whatsapp-sync dependencies and add src/index.js entrypoint - Add whatsme standalone WhatsApp reader app (source only) - Untrack .opencode-sandbox/data/go-crm.db from git history - Expand root .gitignore: ngrok, tmp dirs, sandbox DBs, compiled binaries
This commit is contained in:
244
apps/whatsme/templates/index.gohtml
Normal file
244
apps/whatsme/templates/index.gohtml
Normal file
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>whatsme</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 14px; }
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #0a0a0a;
|
||||
color: #a0a0a0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.status-waiting_qr { color: #d4a017; border-color: #d4a017; }
|
||||
.status-connected { color: #4ade80; border-color: #4ade80; }
|
||||
.status-disconnected { color: #f87171; border-color: #f87171; }
|
||||
.status-failed { color: #f87171; border-color: #f87171; }
|
||||
.status-connecting { color: #60a5fa; border-color: #60a5fa; }
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.panel {
|
||||
background: #0a0a0a;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: #505050;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
#qr-panel { grid-column: 1; grid-row: 1; }
|
||||
#messages-panel { grid-column: 1; grid-row: 2; }
|
||||
#log-panel { grid-column: 2; grid-row: 1 / span 2; }
|
||||
#qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
padding: 1rem;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
#qr-container canvas {
|
||||
image-rendering: pixelated;
|
||||
border: 4px solid #e0e0e0;
|
||||
}
|
||||
#messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 2px solid #2a2a2a;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
.message:hover { border-left-color: #d4a017; }
|
||||
.message-phone { color: #d4a017; font-size: 0.8rem; }
|
||||
.message-name { color: #606060; font-size: 0.7rem; margin-left: 0.5rem; }
|
||||
.message-text { color: #c0c0c0; margin-top: 0.25rem; font-size: 0.85rem; }
|
||||
.message-time { color: #404040; font-size: 0.65rem; margin-top: 0.25rem; }
|
||||
#log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.log-entry { color: #505050; }
|
||||
.log-entry:last-child { color: #a0a0a0; }
|
||||
.prompt { color: #4ade80; }
|
||||
.prompt::before { content: "$ "; }
|
||||
.empty-state {
|
||||
color: #303030;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>whatsme</h1>
|
||||
<span class="status-badge status-{{.State}}">{{.State}}</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="panel" id="qr-panel">
|
||||
<div class="panel-header">QR Code</div>
|
||||
{{if eq .State "waiting_qr"}}
|
||||
{{if .QR}}
|
||||
<div id="qr-container"></div>
|
||||
<input type="hidden" id="qr-code" value="{{.QR}}">
|
||||
{{else}}
|
||||
<div class="empty-state">generating qr code...</div>
|
||||
{{end}}
|
||||
{{else if eq .State "connected"}}
|
||||
<div class="empty-state"><span class="prompt">device connected</span></div>
|
||||
{{else if eq .State "disconnected"}}
|
||||
<div class="empty-state"><span class="prompt">reconnecting...</span></div>
|
||||
{{else if eq .State "failed"}}
|
||||
<div class="empty-state"><span class="prompt">connection failed</span></div>
|
||||
{{else}}
|
||||
<div class="empty-state"><span class="prompt">waiting for connection...</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="panel" id="messages-panel">
|
||||
<div class="panel-header">Messages</div>
|
||||
<div id="messages-list">
|
||||
{{if .Messages}}
|
||||
{{range .Messages}}
|
||||
<div class="message">
|
||||
<span class="message-phone">{{.Phone}}</span>
|
||||
<span class="message-name">{{.Name}}</span>
|
||||
<div class="message-text">{{.Message}}</div>
|
||||
<div class="message-time">{{.Timestamp}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state">no messages yet</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" id="log-panel">
|
||||
<div class="panel-header">Activity Log</div>
|
||||
<div id="log-list">
|
||||
<div class="log-entry"><span class="prompt">whatsme started</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const qrContainer = document.getElementById('qr-container');
|
||||
const qrCodeInput = document.getElementById('qr-code');
|
||||
|
||||
function renderQR(code) {
|
||||
if (!qrContainer) return;
|
||||
qrContainer.innerHTML = '';
|
||||
QRCode.toCanvas(qrContainer, code, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: '#e0e0e0', light: '#0a0a0a' }
|
||||
}, function (err) {
|
||||
if (err) console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (qrCodeInput && qrCodeInput.value) {
|
||||
renderQR(qrCodeInput.value);
|
||||
}
|
||||
|
||||
const evtSource = new EventSource('/events');
|
||||
const messagesList = document.getElementById('messages-list');
|
||||
const logList = document.getElementById('log-list');
|
||||
const statusBadge = document.querySelector('.status-badge');
|
||||
|
||||
evtSource.addEventListener('state', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.qr) {
|
||||
renderQR(data.qr);
|
||||
}
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = data.state;
|
||||
statusBadge.className = 'status-badge status-' + data.state;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener('message', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const msg = data.message;
|
||||
const emptyState = messagesList.querySelector('.empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message';
|
||||
div.innerHTML = `
|
||||
<span class="message-phone">${msg.Phone}</span>
|
||||
<span class="message-name">${msg.Name}</span>
|
||||
<div class="message-text">${msg.Message}</div>
|
||||
<div class="message-time">${new Date(msg.Timestamp * 1000).toLocaleTimeString()}</div>
|
||||
`;
|
||||
messagesList.insertBefore(div, messagesList.firstChild);
|
||||
});
|
||||
|
||||
evtSource.addEventListener('log', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.innerHTML = `<span class="prompt">${data.log}</span>`;
|
||||
logList.appendChild(div);
|
||||
logList.scrollTop = logList.scrollHeight;
|
||||
});
|
||||
|
||||
evtSource.onerror = () => {
|
||||
setTimeout(() => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.innerHTML = `<span class="prompt">[sse reconnecting...]</span>`;
|
||||
logList.appendChild(div);
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user