feat(go-crm): industrial terminal-core UI redesign

Complete visual overhaul of the go-crm web interface:

- New shared layout system (internal/templates/ui.go) with dark zinc
  industrial theme, JetBrains Mono typography, grid/noise textures
- Redesigned all pages: Dashboard, Login/Signup, Clients, Customers,
  Services, Scheduling, Payments, Questions, Answers, Leads,
  Review Queue, Report, Keyword Mapping
- Tailwind CSS via CDN with custom color palette (amber/emerald/rose/sky)
- HTMX-powered interactions with CSS swap animations
- Status pills, KPI cards, data tables, empty states, inline forms
- Mobile-responsive sidebar with collapsible navigation
- All existing tests updated and passing
- Zero new build dependencies — works with existing go run/air workflow
This commit is contained in:
2026-05-23 18:00:32 -03:00
parent 744868caa1
commit 9c3bfd131d
16 changed files with 1901 additions and 791 deletions

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -10,6 +11,7 @@ import (
"time"
"go-crm/internal/db"
"go-crm/internal/templates"
"go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5"
@@ -32,8 +34,8 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
args := []interface{}{accountID}
if search != "" {
query += " AND (name LIKE ? OR phone LIKE ?)"
searchPat := "%" + search + "%"
query += " AND (cu.name LIKE ? OR cu.phone LIKE ?)"
searchPat := "%%" + search + "%%"
args = append(args, searchPat, searchPat)
}
@@ -56,72 +58,50 @@ func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
customers = append(customers, c)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Leads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.search-box { margin-bottom: 1rem; }
.edit-row { display: none; }
</style>
</head>
<body>
<h1>Leads</h1>
<div class="search-box">
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `">
<button type="submit">Search</button>
</form>
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>WhatsApp</th><th>Actions</th></tr>
</thead>
<tbody id="leadList">
`))
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Leads", "Search and manage customer leads"))
for _, c := range customers {
waStatus := "Not connected"
waStyle := "color: #999;"
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
waStatus = c.WhatsAppNumber
waStyle = "color: #28a745; font-weight: 600;"
// Search
fmt.Fprintf(buf, `
<div class="card-industrial p-4 mb-6 animate-slide-up">
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML" class="flex gap-3">
<input type="text" name="search" placeholder="Search by name or phone..." value="%s" class="input-industrial max-w-md">
<button type="submit" class="btn-primary">Search</button>
</form>
</div>`, htmlEscape(search))
if len(customers) == 0 {
fmt.Fprint(buf, templates.EmptyState(`<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>`, "No leads found. Try a different search or connect WhatsApp to capture leads."))
} else {
fmt.Fprintf(buf, `<div class="card-industrial overflow-hidden animate-slide-up stagger-1"><div class="overflow-x-auto">`)
fmt.Fprint(buf, templates.TableStart([]string{"Name", "Phone", "Birth Date", "Instagram", "WhatsApp", "Actions"}))
for _, c := range customers {
waStatus := `<span class="text-zinc-600 text-xs">—</span>`
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
waStatus = fmt.Sprintf(`<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span><span class="font-mono text-xs">%s</span></span>`, htmlEscape(c.WhatsAppNumber))
}
fmt.Fprintf(buf, `<tr>
<td class="font-medium text-zinc-200">%s</td>
<td class="font-mono text-xs">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td class="text-xs text-zinc-400">%s</td>
<td>%s</td>
<td>
<div class="flex items-center gap-2">
<button type="button" onclick="document.getElementById('editLead%d').classList.toggle('hidden')" class="btn-ghost btn-sm">Edit</button>
<form hx-delete="/leads/%d" hx-target="closest tr" hx-swap="outerHTML" style="display:inline">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete?')">Delete</button>
</form>
</div>
</td>
</tr>`, htmlEscape(c.Name), htmlEscape(c.Phone), htmlEscape(c.BirthDate), htmlEscape(c.Instagram), waStatus, c.CustomerID, c.CustomerID)
}
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + c.BirthDate + `</td>
<td>` + c.Instagram + `</td>
<td style="` + waStyle + `">` + waStatus + `</td>
<td>
<button type="button" onclick="document.getElementById('editLead` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='table-row'">Edit</button>
<form method="DELETE" style="display:inline" hx-delete="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
<td colspan="6">
<form hx-put="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="date" name="birth_date" value="` + c.BirthDate + `">
<input type="text" name="instagram" value="` + c.Instagram + `">
<button type="submit">Save</button>
</form>
</td>
</tr>`))
fmt.Fprint(buf, templates.TableEnd())
fmt.Fprintf(buf, `</div></div>`)
}
w.Write([]byte(`</tbody></table>
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
</body></html>`))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Leads", "leads")
}
func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) {
@@ -163,7 +143,7 @@ func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("OK"))
w.Write([]byte(""))
}
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
@@ -179,81 +159,71 @@ func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Connect WhatsApp</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
body { font-family: sans-serif; padding: 2rem; text-align: center; }
#qrcode { margin: 2rem auto; display: flex; justify-content: center; }
#status { padding: 1rem; }
</style>
</head>
<body>
<h1>Connect WhatsApp</h1>
<p>Scan the QR code below with your WhatsApp app to connect</p>
<div id="qrcode"></div>
<p id="status">Loading...</p>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json())
.then(data => {
if (data.qr) {
if (data.qr !== lastQR) {
lastQR = data.qr;
document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), {
text: data.qr,
width: 256,
height: 256
});
buf := templates.BufRender()
fmt.Fprint(buf, templates.PageHeader("Connect WhatsApp", fmt.Sprintf("Scan QR code to link %s", htmlEscape(client.Name))))
fmt.Fprintf(buf, `
<div class="max-w-lg mx-auto animate-slide-up">
<div class="card-industrial p-8 text-center">
<div id="qrcode" class="flex justify-center mb-6 min-h-[256px] items-center">
<div class="spinner"></div>
</div>
<p id="status" class="text-sm text-zinc-400 font-mono">Loading QR code...</p>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=%d')
.then(r => r.json())
.then(data => {
if (data.qr) {
if (data.qr !== lastQR) {
lastQR = data.qr;
document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), { text: data.qr, width: 256, height: 256 });
}
document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000);
} else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected! Verifying phone...';
document.getElementById('qrcode').innerHTML = '<div class="w-16 h-16 rounded-full bg-emerald-400/10 flex items-center justify-center mx-auto"><svg class="w-8 h-8 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>';
setTimeout(() => {
fetch('/leads/verify/%d')
.then(r => r.json())
.then(v => {
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
if (v.match === 'yes') {
document.getElementById('status').innerHTML = msg + ' matches client phone &mdash; <span class="text-emerald-400">verified</span>';
} else if (v.match === 'no') {
document.getElementById('status').innerHTML = msg + ' does NOT match client phone &mdash; <span class="text-rose-400">mismatch</span>';
} else {
document.getElementById('status').textContent = msg + ' (client phone unknown)';
}
document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000);
} else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected! Verifying phone...';
document.getElementById('qrcode').innerHTML = '&#10003;';
setTimeout(() => {
fetch('/leads/verify/` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json())
.then(v => {
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
if (v.match === 'yes') {
document.getElementById('status').textContent = msg + ' — matches ' + (v.client_phone || '') + ' ✓';
document.getElementById('status').style.color = '#28a745';
} else if (v.match === 'no') {
document.getElementById('status').textContent = msg + ' — does NOT match client phone ' + (v.client_phone || '') + ' ⚠';
document.getElementById('status').style.color = '#dc3545';
} else {
document.getElementById('status').textContent = msg + ' (client phone unknown — verify manually)';
}
})
.catch(() => {
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
});
}, 1500);
} else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
setTimeout(pollQR, 8000);
} else {
document.getElementById('status').textContent = 'Status: ' + data.status;
setTimeout(pollQR, 5000);
}
})
.catch(err => {
document.getElementById('status').textContent = 'Connection error — retrying...';
setTimeout(pollQR, 5000);
});
}
pollQR();
</script>
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
</body></html>`))
})
.catch(() => {
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
});
}, 1500);
} else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' retrying...';
setTimeout(pollQR, 8000);
} else {
document.getElementById('status').textContent = 'Status: ' + data.status;
setTimeout(pollQR, 5000);
}
})
.catch(err => {
document.getElementById('status').textContent = 'Connection error retrying...';
setTimeout(pollQR, 5000);
});
}
pollQR();
</script>`, client.ClientID, client.ClientID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.WritePage(w, buf, "Connect WhatsApp", "leads")
}
func jsonEscape(s string) string {
@@ -349,8 +319,8 @@ func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
match := "unknown"
if client.WhatsAppNumber != "" && client.Phone != "" {
cleanWA := strings.TrimPrefix(client.WhatsAppNumber, "+")
cleanClient := strings.TrimPrefix(client.Phone, "+")
cleanWA := cleanPhone(client.WhatsAppNumber)
cleanClient := cleanPhone(client.Phone)
if cleanWA == cleanClient {
match = "yes"
} else {
@@ -362,6 +332,12 @@ func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok","wa_phone":"` + jsonEscape(client.WhatsAppNumber) + `","client_phone":"` + jsonEscape(client.Phone) + `","match":"` + match + `","client_name":"` + jsonEscape(client.Name) + `"}`))
}
func cleanPhone(s string) string {
s = strings.TrimPrefix(s, "+")
s = strings.TrimPrefix(s, "55")
return s
}
// --- package-level shims kept for existing tests ---
func ListLeads(w http.ResponseWriter, r *http.Request) {