/* MENOUNO — Gestione Recensioni
Fetches from FastAPI backend at /api/ */
const API = ""; // same origin
const RISULTATO_OPTIONS = [
{ value: "", label: "— nessuno" },
{ value: "in-attesa", label: "In attesa" },
{ value: "lasciata", label: "Recensione lasciata" },
{ value: "nessuna", label: "Nessuna risposta" },
{ value: "rifiutata", label: "Rifiutata" },
];
// ── Status computation (mirrors backend logic) ────────────────────────────────
function computeStatus(richiesta, risultato) {
const r = (risultato || "").toLowerCase();
const req = (richiesta || "").toLowerCase();
if (r.includes("lasciata")) return "lasciata";
if (r.includes("nessuna")) return "nessuna";
if (r.includes("rifiutata")) return "rifiutata";
if (req === "richiesta inviata") return "in-attesa";
if (req.startsWith("non richiesta")) return "non-richiesta";
return "da-inviare";
}
// ── Single table row ──────────────────────────────────────────────────────────
function ClientRow({ client: c, onPatch, onSend }) {
return (
{/* Cliente */}
{fullName(c)}
{c.method === "mail" ? c.email : c.phone}
{/* Contatto */}
{methodLabel(c.method)}
{/* Tatuatore */}
{c.artist || "—"}
{/* Soddisfazione */}
onPatch({ satisfaction: v })}
compact
/>
{/* Richiesta */}
{c.status === "non-richiesta" && c.richiesta_recensione && (
{c.richiesta_recensione}
)}
{/* Modalità */}
{/* Risultato */}
{/* Stelle */}
onPatch({ stars: v })}/>
{/* Foto */}
{/* Azione */}
);
}
// ── Main App ──────────────────────────────────────────────────────────────────
function App() {
const [clients, setClients] = React.useState([]);
const [messages, setMessages] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [selectedMsg, setSelectedMsg] = React.useState(null);
const [filter, setFilter] = React.useState("tutti");
const [query, setQuery] = React.useState("");
const [sideOpen, setSideOpen] = React.useState(true);
const [editing, setEditing] = React.useState(null);
const [refreshing, setRefreshing] = React.useState(false);
const [toast, showToast] = useToast();
// ── Fetch ─────────────────────────────────────────────────────────────────
// Messages: local JSON, fast — runs independently, never blocks the table skeleton
const fetchMessages = React.useCallback(async () => {
try {
const msgs = await fetch(API + "/api/messages").then(r => r.json());
setMessages(msgs);
setSelectedMsg(sel => {
const ids = msgs.map(m => m.id);
return ids.includes(sel) ? sel : (msgs[0]?.id || null);
});
} catch {}
}, []);
// Clients: Google Sheets — controls table loading/error state
const fetchClients = React.useCallback(async (showSpinner = true) => {
if (showSpinner) setRefreshing(true);
try {
const cls = await fetch(API + "/api/clients").then(r => {
if (!r.ok) throw new Error("Errore caricamento clienti");
return r.json();
});
setClients(cls);
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
React.useEffect(() => {
fetchMessages(); // fast, non-blocking
fetchClients(false); // controls table skeleton
}, []);
// ── Client mutations ───────────────────────────────────────────────────────
const patchClient = React.useCallback(async (id, updates) => {
// Optimistic UI update
setClients(cs => cs.map(c => {
if (c.id !== id) return c;
const next = { ...c, ...updates };
next.status = computeStatus(
updates.richiesta_recensione ?? c.richiesta_recensione,
updates.risultato ?? c.risultato
);
return next;
}));
try {
const res = await fetch(API + "/api/clients/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error(await res.text());
} catch (e) {
showToast("⚠️ Errore salvataggio: " + e.message);
fetchClients(false); // roll back optimistic update
}
}, [fetchClients, showToast]);
const handleSend = React.useCallback((client) => {
const msg = messages.find(m => m.id === selectedMsg);
const url = buildContactURL(client, msg);
try {
if (client.method === "call") { window.location.href = url; }
else { window.open(url, "_blank", "noopener"); }
} catch {}
showToast("Aperto contatto con " + client.first + " via " + methodLabel(client.method));
}, [messages, selectedMsg, showToast]);
// ── Counts & filter ────────────────────────────────────────────────────────
const counts = React.useMemo(() => {
const c = { tutti: clients.length, "da-inviare": 0, "in-attesa": 0, "lasciata": 0, "altro": 0, "non-richiesta": 0 };
clients.forEach(cl => {
if (cl.status === "da-inviare") c["da-inviare"]++;
else if (cl.status === "in-attesa") c["in-attesa"]++;
else if (cl.status === "lasciata") c["lasciata"]++;
else if (cl.status === "non-richiesta") c["non-richiesta"]++;
else c["altro"]++;
});
return c;
}, [clients]);
const filtered = React.useMemo(() => {
return clients.filter(c => {
if (filter === "da-inviare" && c.status !== "da-inviare") return false;
if (filter === "in-attesa" && c.status !== "in-attesa") return false;
if (filter === "lasciata" && c.status !== "lasciata") return false;
if (filter === "non-richiesta"&& c.status !== "non-richiesta")return false;
if (filter === "altro" && !["nessuna","rifiutata"].includes(c.status)) return false;
if (query) {
const q = query.toLowerCase();
return (
(c.first + " " + c.last).toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.phone.includes(q)
);
}
return true;
});
}, [clients, filter, query]);
const message = messages.find(m => m.id === selectedMsg);
// ── Message CRUD ───────────────────────────────────────────────────────────
const saveMessage = async m => {
try {
if (m.id) {
const res = await fetch(API + "/api/messages/" + m.id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: m.title, body: m.body }),
});
const updated = await res.json();
setMessages(ms => ms.map(x => x.id === m.id ? updated : x));
} else {
const res = await fetch(API + "/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: m.title, body: m.body }),
});
const created = await res.json();
setMessages(ms => [...ms, created]);
setSelectedMsg(created.id);
}
setEditing(null);
showToast("Messaggio salvato");
} catch (e) {
showToast("⚠️ Errore: " + e.message);
}
};
const deleteMessage = async id => {
await fetch(API + "/api/messages/" + id, { method: "DELETE" });
setMessages(ms => ms.filter(x => x.id !== id));
if (selectedMsg === id) {
const rem = messages.filter(x => x.id !== id);
setSelectedMsg(rem[0]?.id || null);
}
showToast("Messaggio eliminato");
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
{/* ── Top bar ──────────────────────────────────────────────────────── */}
MENOUNO
/ FRONT OFFICE — RECENSIONI
setQuery(e.target.value)}
/>
{query && (
)}
{/* ── Chips ────────────────────────────────────────────────────────── */}
{/* ── Body ─────────────────────────────────────────────────────────── */}
{/* TABLE */}
Cliente
Contatto pref.
Tatuatore
Soddisfaz.
Richiesta
Modalità
Risultato
Stelle
Foto
Azione
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
))
) : error ? (
⚠️ {error}
) : (
<>
{filtered.map(c => (
patchClient(c.id, updates)}
onSend={() => handleSend(c)}
/>
))}
{filtered.length === 0 && (
Nessun cliente trovato.
)}
>
)}
{/* SIDEBAR */}
{sideOpen && (
)}
{toast}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();