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
361 lines
14 KiB
Go
361 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-crm/internal/db"
|
|
"go-crm/internal/templates"
|
|
"go-crm/internal/whatsapp"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
search := r.URL.Query().Get("search")
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if limit == 0 {
|
|
limit = 20
|
|
}
|
|
|
|
query := "SELECT cu.customer_id, cu.client_id, cu.name, cu.phone, cu.birth_date, cu.instagram, cu.created_at, COALESCE(cl.whatsapp_connected,0), COALESCE(cl.whatsapp_number,'') FROM customers cu JOIN clients cl ON cu.client_id = cl.client_id WHERE cl.account_id = ?"
|
|
args := []interface{}{accountID}
|
|
|
|
if search != "" {
|
|
query += " AND (cu.name LIKE ? OR cu.phone LIKE ?)"
|
|
searchPat := "%%" + search + "%%"
|
|
args = append(args, searchPat, searchPat)
|
|
}
|
|
|
|
query += " ORDER BY cu.created_at DESC LIMIT ? OFFSET ?"
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := a.DB.Query(query, args...)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var customers []db.Customer
|
|
for rows.Next() {
|
|
var c db.Customer
|
|
if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt, &c.WhatsAppConnected, &c.WhatsAppNumber); err != nil {
|
|
continue
|
|
}
|
|
customers = append(customers, c)
|
|
}
|
|
|
|
buf := templates.BufRender()
|
|
fmt.Fprint(buf, templates.PageHeader("Leads", "Search and manage customer leads"))
|
|
|
|
// 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)
|
|
}
|
|
fmt.Fprint(buf, templates.TableEnd())
|
|
fmt.Fprintf(buf, `</div></div>`)
|
|
}
|
|
|
|
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) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
r.ParseForm()
|
|
|
|
_, err := a.DB.Exec(
|
|
"UPDATE customers SET name = ?, phone = ?, birth_date = ?, instagram = ? WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
|
|
r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id, accountID,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
a.ListLeads(w, r)
|
|
}
|
|
|
|
func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
|
|
_, err := a.DB.Exec(
|
|
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
|
|
id, accountID,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(""))
|
|
}
|
|
|
|
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
|
client, err := db.GetClientByID(a.DB, accountID, clientID)
|
|
if err != nil {
|
|
http.Error(w, "Client not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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 — <span class="text-emerald-400">verified</span>';
|
|
} else if (v.match === 'no') {
|
|
document.getElementById('status').innerHTML = msg + ' does NOT match client phone — <span class="text-rose-400">mismatch</span>';
|
|
} else {
|
|
document.getElementById('status').textContent = msg + ' (client phone unknown)';
|
|
}
|
|
})
|
|
.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 {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
func (a *App) LeadsQR(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
|
client, err := db.GetClientByID(a.DB, accountID, clientID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"status":"error","error":"client not found"}`))
|
|
return
|
|
}
|
|
|
|
if a.WAConnector == nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
|
|
return
|
|
}
|
|
|
|
connected, err := a.WAConnector.IsConnected(r.Context(), clientID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
|
|
return
|
|
}
|
|
if connected {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
log.Printf("QR: Starting Connect for client %d", clientID)
|
|
|
|
qrChan, err := a.WAConnector.Connect(context.Background(), clientID)
|
|
if err != nil {
|
|
log.Printf("QR: Connect returned error for client %d: %v", clientID, err)
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
|
|
return
|
|
}
|
|
|
|
timeout := time.After(30 * time.Second)
|
|
for {
|
|
select {
|
|
case <-timeout:
|
|
log.Printf("QR: Timeout waiting for client %d", clientID)
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape("timeout waiting for QR code") + `}`))
|
|
return
|
|
case frame, ok := <-qrChan:
|
|
if !ok {
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":"connection closed"}`))
|
|
return
|
|
}
|
|
if frame.QR != "" {
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"qr":` + jsonEscape(frame.QR) + `,"status":"waiting"}`))
|
|
return
|
|
}
|
|
if frame.State == whatsapp.StateConnected {
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
|
|
return
|
|
}
|
|
if frame.State == whatsapp.StateFailed {
|
|
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(frame.Error) + `}`))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
clientID, _ := strconv.ParseInt(chi.URLParam(r, "client_id"), 10, 64)
|
|
client, err := db.GetClientByID(a.DB, accountID, clientID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write([]byte(`{"status":"error","error":"client not found"}`))
|
|
return
|
|
}
|
|
|
|
match := "unknown"
|
|
if client.WhatsAppNumber != "" && client.Phone != "" {
|
|
cleanWA := cleanPhone(client.WhatsAppNumber)
|
|
cleanClient := cleanPhone(client.Phone)
|
|
if cleanWA == cleanClient {
|
|
match = "yes"
|
|
} else {
|
|
match = "no"
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
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) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).ListLeads(w, r)
|
|
}
|
|
func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).UpdateLead(w, r)
|
|
}
|
|
func DeleteLead(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).DeleteLead(w, r)
|
|
}
|
|
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).LeadsConnectPage(w, r)
|
|
}
|
|
func LeadsQR(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).LeadsQR(w, r)
|
|
}
|
|
func VerifyLead(w http.ResponseWriter, r *http.Request) {
|
|
(&App{DB: DB, WAConnector: WAConnector}).VerifyLead(w, r)
|
|
}
|