feat(chat): chat a canali nel portale cliente e inbox admin per canale

Il portale aveva una sola conversazione con un selettore di fase in un
dropdown: il cliente non vedeva dove c'era del non letto, e una risposta
admin poteva atterrare su un'entità diversa da quella della domanda.

Ora i messaggi si organizzano in canali — "Generale" più uno per fase —
derivati in un solo posto (src/lib/chat-channels.ts) così che le due
sponde concordino sulla stessa chiave. Task e deliverable non sono più
scrivibili ma lo storico non resta orfano: rientra nel canale della fase
proprietaria conservando il nome dell'entità come badge.

- migration 0021 (additiva, già applicata in prod): client_channel_reads
  per il letto/non-letto per canale lato cliente, più il primo indice mai
  esistito su comments (entity_id, created_at)
- GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura
- ChatPanel: tab per canale, pallino di non letto, modalità full-screen
- inbox admin: tab per canale con targeting dell'entità corretta in
  risposta e snapshot di adminLastReadAt sul thread

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:50:44 +02:00
parent 53f1758f52
commit ac74a81a72
14 changed files with 1320 additions and 332 deletions
@@ -85,7 +85,10 @@ export function ConversationsView({
{/* ── Right: active thread ────────────────────────────────── */}
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
{activeThread ? (
<ActiveThread thread={activeThread} />
// key sul cliente: al refresh (che segna letta la conversazione) il
// componente NON rimonta e tiene i pallini calcolati all'apertura;
// cambiando cliente rimonta e li ricalcola sui dati freschi.
<ActiveThread key={activeThread.clientId} thread={activeThread} />
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
@@ -153,11 +156,53 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
const scrollRef = useRef<HTMLDivElement>(null);
const portalHref = `/client/${thread.slug ?? thread.token}`;
// Canali con messaggi del cliente non ancora letti, fotografati al montaggio.
// Non è un useMemo: dopo markConversationRead + refresh il calcolo darebbe
// sempre insieme vuoto e i pallini sparirebbero prima di essere visti.
const [unreadAtOpen] = useState(() => {
const lastReadAt = thread.adminLastReadAt?.getTime() ?? 0;
const set = new Set<string>();
for (const m of thread.messages) {
if (m.author === "client" && new Date(m.created_at).getTime() > lastReadAt) {
set.add(m.channelKey);
}
}
return set;
});
const [visited, setVisited] = useState<Set<string>>(new Set());
// Si apre dove c'è qualcosa da leggere; in mancanza, sul canale dell'ultimo
// messaggio. Aprire sempre su "Generale" costringerebbe a cercare a mano il
// tab da cui è arrivata la domanda.
const [activeChannel, setActiveChannel] = useState(() => {
const firstUnread = thread.messages.find(
(m) => m.author === "client" && unreadAtOpen.has(m.channelKey)
);
if (firstUnread) return firstUnread.channelKey;
const last = thread.messages[thread.messages.length - 1];
return last?.channelKey ?? thread.clientId;
});
function selectChannel(key: string) {
setActiveChannel(key);
setVisited((prev) => new Set(prev).add(key));
}
const visibleMessages = useMemo(
() => thread.messages.filter((m) => m.channelKey === activeChannel),
[thread.messages, activeChannel]
);
const activeLabel =
thread.channels.find((c) => c.key === activeChannel)?.label ?? "Generale";
const isGeneral = activeChannel === thread.clientId;
const showTabs = thread.channels.length > 1;
// Keep the thread pinned to the latest message.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [thread.clientId, thread.messages.length]);
}, [activeChannel, visibleMessages.length]);
return (
<>
@@ -184,27 +229,68 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
</div>
</div>
{/* Canali — stessi tab che vede il cliente nel portale */}
{showTabs && (
<div
className="no-scrollbar flex gap-1 overflow-x-auto border-b border-border px-4 py-2"
role="tablist"
aria-label="Canali della conversazione"
>
{thread.channels.map((channel) => {
const active = channel.key === activeChannel;
const unread = unreadAtOpen.has(channel.key) && !visited.has(channel.key);
return (
<button
key={channel.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => selectChannel(channel.key)}
title={channel.label}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",
active
? "bg-muted font-semibold text-foreground"
: "font-medium text-muted-foreground hover:text-foreground"
)}
>
<span className="max-w-[120px] truncate">{channel.label}</span>
{unread && !active && (
<>
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500" />
<span className="sr-only">non letti</span>
</>
)}
</button>
);
})}
</div>
)}
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
{thread.messages.length === 0 ? (
{visibleMessages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
Nessun messaggio in questa conversazione.
Nessun messaggio in questo canale.
</p>
) : (
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
)}
</div>
{/* Reply box */}
{/* Reply box — il canale viaggia col messaggio */}
<form
action={replyToConversation.bind(null, thread.clientId)}
className="p-4 border-t border-border bg-card flex items-end gap-3"
>
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
<input type="hidden" name="entity_id" value={activeChannel} />
<Textarea
name="body"
rows={2}
required
placeholder="Scrivi una risposta..."
placeholder={`Rispondi in ${activeLabel}...`}
aria-label={`Rispondi nel canale ${activeLabel}`}
className="flex-1 resize-none"
/>
<Button type="submit" size="sm" className="shrink-0">
@@ -221,7 +307,9 @@ function MessageBubble({
message: ConversationThread["messages"][number];
}) {
const isAdmin = message.author === "admin";
const showEntity = message.entityType !== "general";
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
const showEntity =
message.entityType === "task" || message.entityType === "deliverable";
return (
<div className={cn("flex", isAdmin && "justify-end")}>
<div
+5 -4
View File
@@ -1,5 +1,5 @@
import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema';
import type { ChatData } from '@/lib/chat-channels';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
@@ -16,7 +16,8 @@ import { PreviewProvider } from './client/PreviewProvider';
interface ClientDashboardProps {
view: ClientView;
token: string;
comments: Comment[];
/** Messaggi + ricevute di lettura della chat, già scoped a questo progetto. */
chat: ChatData;
/** When rendered inside the multi-project tabs wrapper, the page already
* provides the portal header + footer — skip them here to avoid duplicates. */
embedded?: boolean;
@@ -24,7 +25,7 @@ interface ClientDashboardProps {
preview?: boolean;
}
export function ClientDashboard({ view, token, comments, embedded = false, preview = false }: ClientDashboardProps) {
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
// Determine payment display mode based on active offers.
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
@@ -160,7 +161,7 @@ export function ClientDashboard({ view, token, comments, embedded = false, previ
</footer>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} comments={comments} />
<ChatPanel token={token} chat={chat} />
</div>
</ChatProvider>
</PreviewProvider>
+563 -203
View File
@@ -1,277 +1,637 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Comment } from "@/db/schema";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Maximize2, Minimize2, MessageCircle, Send, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
buildChannels,
buildChannelIndex,
channelOfComment,
type ChatData,
type ChatMessage,
} from "@/lib/chat-channels";
import { useChatContext } from "./ChatProvider";
import { usePreview } from "./PreviewProvider";
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here.
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
// una query su client_channel_reads incrociata con comments.author = 'admin'.
function formatTime(ts: Date | string): string {
const d = typeof ts === "string" ? new Date(ts) : ts;
return d.toLocaleString("it-IT", {
day: "2-digit",
month: "short",
const POLL_MS = 20_000;
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
const GROUP_WINDOW_MS = 5 * 60_000;
type PendingMessage = {
tempId: string;
channelKey: string;
body: string;
failed: boolean;
};
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function formatTime(value: Date | string): string {
return toDate(value).toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
});
}
interface ChatPanelProps {
token: string;
comments: Comment[];
function formatDayDivider(value: Date | string): string {
const d = toDate(value);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Oggi";
if (d.toDateString() === yesterday.toDateString()) return "Ieri";
return d.toLocaleDateString("it-IT", {
weekday: "long",
day: "numeric",
month: "long",
});
}
export function ChatPanel({ token, comments }: ChatPanelProps) {
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
function monogram(label: string): string {
const parts = label.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
const {
isOpen,
expanded,
activeChannel,
setActiveChannel,
phases,
clientId,
openChat,
closeChat,
toggleExpanded,
} = useChatContext();
const preview = usePreview();
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
const index = useMemo(() => buildChannelIndex(clientId, phases), [clientId, phases]);
// `chat.messages` è la base che arriva dal server a ogni render RSC; `polled`
// raccoglie ciò che il poll e gli invii hanno imparato dopo. Si fondono per id,
// così quando il server recupera il ritardo i doppioni collassano da soli
// invece di comparire due volte.
const [polled, setPolled] = useState<ChatMessage[]>([]);
const [reads, setReads] = useState<Record<string, string>>(chat.reads);
const [pending, setPending] = useState<PendingMessage[]>([]);
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
const bottomRef = useRef<HTMLDivElement>(null);
const fabRef = useRef<HTMLButtonElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Build entity list: Generale + phases
type EntityOption = { id: string; type: "general" | "phase"; label: string };
const entityOptions: EntityOption[] = [
{ id: clientId, type: "general", label: "Generale" },
...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })),
];
const messages = useMemo(() => {
const byId = new Map<string, ChatMessage>();
for (const m of chat.messages) byId.set(m.id, m);
for (const m of polled) byId.set(m.id, m);
return [...byId.values()].sort(
(a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime()
);
}, [chat.messages, polled]);
// Build label map for displaying tags on existing messages
const labelMap = new Map<string, string>();
labelMap.set(clientId, "Generale");
for (const p of phases) {
labelMap.set(p.id, p.title);
for (const t of p.tasks) {
labelMap.set(t.id, `${p.title}${t.title}`);
for (const d of t.deliverables) {
labelMap.set(d.id, `${t.title}${d.title}`);
// ── Non letti per canale ────────────────────────────────────────────────────
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
// il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto.
const unreadChannels = useMemo(() => {
const lastAdminAt = new Map<string, number>();
for (const m of messages) {
if (m.author !== "admin") continue;
const key = channelOfComment(m, index, clientId);
const at = toDate(m.created_at).getTime();
if (at > (lastAdminAt.get(key) ?? 0)) lastAdminAt.set(key, at);
}
const unread = new Set<string>();
for (const [key, at] of lastAdminAt) {
const readAt = reads[key];
if (!readAt || at > new Date(readAt).getTime()) unread.add(key);
}
return unread;
}, [messages, reads, index, clientId]);
const visibleMessages = useMemo(
() => messages.filter((m) => channelOfComment(m, index, clientId) === activeChannel),
[messages, index, clientId, activeChannel]
);
const visiblePending = useMemo(
() => pending.filter((p) => p.channelKey === activeChannel),
[pending, activeChannel]
);
const activeLabel =
channels.find((c) => c.key === activeChannel)?.label ?? channels[0]?.label ?? "";
// ── Polling ────────────────────────────────────────────────────────────────
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
// nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero.
const sinceRef = useRef<string | null>(null);
useEffect(() => {
const latest = messages[messages.length - 1];
sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null;
}, [messages]);
useEffect(() => {
// In anteprima admin non si scrive e non serve inseguire le novità.
if (!isOpen || preview) return;
let aborted = false;
async function poll() {
if (document.visibilityState !== "visible") return;
const params = new URLSearchParams({ token, project_id: chat.projectId });
if (sinceRef.current) params.set("since", sinceRef.current);
try {
const res = await fetch(`/api/client/chat?${params}`);
if (!res.ok || aborted) return;
const data = (await res.json()) as {
comments: ChatMessage[];
reads: Record<string, string>;
};
if (aborted) return;
if (data.comments?.length) {
setPolled((prev) => [...prev, ...data.comments]);
}
if (data.reads) setReads((prev) => ({ ...prev, ...data.reads }));
} catch {
// Rete assente o richiesta caduta: si riprova al giro dopo, in silenzio.
// Un errore a schermo ogni 20 secondi sarebbe peggio del ritardo.
}
}
}
// Current selection: sync with selectedPhaseId from context
const [currentEntityId, setCurrentEntityId] = useState<string>(clientId);
useEffect(() => {
setCurrentEntityId(selectedPhaseId ?? clientId);
}, [selectedPhaseId, clientId]);
const id = setInterval(poll, POLL_MS);
// Tornando sulla scheda si recupera subito, senza aspettare il tick.
const onVisible = () => {
if (document.visibilityState === "visible") poll();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
aborted = true;
clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
}, [isOpen, preview, token, chat.projectId]);
// Sort all comments chronologically
const sorted = [...comments].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
// ── Ricevuta di lettura ────────────────────────────────────────────────────
const markRead = useCallback(
async (channelKey: string) => {
// Ottimistico: il pallino sparisce appena guardi il tab, non dopo il POST.
setReads((prev) => ({ ...prev, [channelKey]: new Date().toISOString() }));
try {
await fetch("/api/client/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, channel_key: channelKey }),
});
} catch {
// Se non passa, il pallino torna al prossimo poll. Non vale un errore.
}
},
[token]
);
useEffect(() => {
if (isOpen) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
if (!isOpen || preview) return;
if (!unreadChannels.has(activeChannel)) return;
const id = setTimeout(() => markRead(activeChannel), 400);
return () => clearTimeout(id);
}, [isOpen, preview, activeChannel, unreadChannels, markRead]);
// ── Scroll, focus, tastiera ────────────────────────────────────────────────
useEffect(() => {
if (isOpen) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [isOpen, activeChannel, visibleMessages.length, visiblePending.length]);
useEffect(() => {
if (isOpen && !preview) textareaRef.current?.focus();
}, [isOpen, preview]);
useEffect(() => {
if (!isOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key !== "Escape") return;
// Esc scala di un livello per volta: prima esce dal tutto schermo, poi chiude.
if (expanded) toggleExpanded();
else {
closeChat();
fabRef.current?.focus();
}
}
}, [isOpen, comments.length]);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, expanded, toggleExpanded, closeChat]);
function handleSend(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
if (!trimmed) return;
function handleClose() {
closeChat();
fabRef.current?.focus();
}
const selectedEntity = entityOptions.find((en) => en.id === currentEntityId);
if (!selectedEntity) return;
setError(null);
startTransition(async () => {
// ── Invio ──────────────────────────────────────────────────────────────────
const send = useCallback(
async (text: string, channelKey: string, tempId: string) => {
const isGeneral = channelKey === clientId;
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
entity_type: selectedEntity.type,
entity_id: selectedEntity.id,
body: trimmed,
entity_type: isGeneral ? "general" : "phase",
entity_id: channelKey,
body: text,
}),
});
if (!res.ok) {
const data = await res.json();
const data = await res.json().catch(() => ({}));
setError(data.error ?? "Errore nell'invio");
setPending((prev) =>
prev.map((p) => (p.tempId === tempId ? { ...p, failed: true } : p))
);
return;
}
setBody("");
router.refresh();
const data = (await res.json()) as { comment?: ChatMessage };
// La riga vera prende il posto della copia locale: stesso id del server,
// così il prossimo render RSC non la duplica.
if (data.comment) setPolled((prev) => [...prev, data.comment!]);
setPending((prev) => prev.filter((p) => p.tempId !== tempId));
} catch {
setError("Errore di rete");
setPending((prev) =>
prev.map((p) => (p.tempId === tempId ? { ...p, failed: true } : p))
);
}
});
},
[token, clientId]
);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
if (!trimmed) return;
const tempId = `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`;
setError(null);
setPending((prev) => [
...prev,
{ tempId, channelKey: activeChannel, body: trimmed, failed: false },
]);
setBody("");
void send(trimmed, activeChannel, tempId);
}
function retry(item: PendingMessage) {
setError(null);
setPending((prev) =>
prev.map((p) => (p.tempId === item.tempId ? { ...p, failed: false } : p))
);
void send(item.body, item.channelKey, item.tempId);
}
function discard(tempId: string) {
setPending((prev) => prev.filter((p) => p.tempId !== tempId));
setError(null);
}
const hasAnyUnread = unreadChannels.size > 0;
// Un solo canale (cliente a retainer, senza fasi): la striscia di tab non
// aggiungerebbe nulla e ruberebbe altezza al feed.
const showTabs = channels.length > 1;
return (
<>
{/* FAB — floating action button bottom-right */}
{/* FAB */}
<button
ref={fabRef}
type="button"
onClick={() => (isOpen ? closeChat() : openChat())}
aria-label="Apri messaggi"
className="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-[#1A463C] text-white shadow-lg hover:bg-[#163a31] transition-colors flex items-center justify-center"
onClick={() => (isOpen ? handleClose() : openChat(activeChannel))}
aria-label={isOpen ? "Chiudi messaggi" : "Apri messaggi"}
aria-expanded={isOpen}
className="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{isOpen ? (
/* X close icon */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
/* Chat bubble + plus SVG */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{isOpen ? <X className="h-6 w-6" /> : <MessageCircle className="h-6 w-6" />}
{!isOpen && hasAnyUnread && (
<span
className="absolute right-1 top-1 h-3.5 w-3.5 rounded-full border-2 border-background bg-accent"
aria-hidden="true"
/>
)}
{!isOpen && hasAnyUnread && <span className="sr-only">Ci sono messaggi non letti</span>}
</button>
{/* Overlay — subtle backdrop on mobile */}
{/* Backdrop: solo dove il pannello copre davvero la pagina */}
{isOpen && (
<div
className="fixed inset-0 z-50 bg-black/10 lg:hidden"
onClick={closeChat}
className={cn("fixed inset-0 z-50 bg-foreground/10", !expanded && "lg:hidden")}
onClick={handleClose}
aria-hidden="true"
/>
)}
{/* Slide-in panel — z-[60] so it sits above the sticky page header (z-50) */}
{/* Pannello. Larghezza animata invece che inset: `left` da auto a 0 non
transiziona, e il salto si vedrebbe a ogni espansione. */}
<div
className={`fixed top-0 right-0 h-full z-[60] w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
className={cn(
"fixed inset-y-0 right-0 z-[60] flex flex-col bg-card shadow-2xl",
"transition-[transform,width] duration-300 ease-in-out",
expanded ? "w-full" : "w-full sm:w-[460px]",
isOpen ? "translate-x-0" : "translate-x-full"
}`}
aria-label="Messaggi & Revisioni"
)}
role="dialog"
aria-modal="true"
aria-label="Messaggi & Revisioni"
aria-modal={expanded ? true : undefined}
// Chiuso resta nel DOM per animare: senza `inert` Tab e screen reader ci
// finirebbero comunque dentro.
inert={!isOpen}
>
{/* Vertical "Chiudi" tab — notebook-divider style, protrudes from the left edge */}
<button
type="button"
onClick={closeChat}
aria-label="Chiudi"
className="absolute left-0 top-1/2 flex -translate-x-full -translate-y-1/2 flex-col items-center gap-2 rounded-l-xl bg-[#1A463C] px-2 py-4 text-white shadow-lg transition-colors hover:bg-[#163a31]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] [writing-mode:vertical-rl]">
Chiudi
</span>
</button>
{/* Panel header */}
<div className="flex items-center px-5 py-4 border-b border-[#e5e7eb] shrink-0">
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
</div>
{/* Chat feed */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{sorted.length === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-10">
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
</p>
)}
{sorted.map((c) => {
const isClient = c.author === "client";
const entityLabel = labelMap.get(c.entity_id);
const showTag = c.entity_id !== clientId && entityLabel;
return (
<div
key={c.id}
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
>
{/* Author + tag */}
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
isClient
? "bg-[#DEF168] text-[#1A463C]"
: "bg-[#1A463C] text-white"
}`}
>
{isClient ? "Tu" : "iamcavalli"}
</span>
{showTag && (
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[160px]">
{entityLabel}
</span>
)}
</div>
{/* Bubble */}
<div
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
isClient
? "bg-[#1A463C] text-white rounded-tr-sm"
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
}`}
>
{c.body}
</div>
{/* Timestamp */}
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Divider */}
<div className="border-t border-[#e5e7eb]" />
{/* Composer — in anteprima admin lo storico resta visibile (serve proprio
a vedere cosa legge il cliente) ma non si può scrivere al posto suo.
Colori in hex come nel resto del pannello: questo componente è
un'isola forzata a bg-white, i token semantici qui renderebbero
testo chiaro su fondo chiaro in dark mode. */}
{preview ? (
<div className="p-3 bg-[#fafafa] shrink-0 text-center text-xs text-[#71717a]">
Composer disattivato in anteprima
{/* Header */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5">
<div className="flex min-w-0 items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
<h2 className="truncate text-sm font-bold text-foreground" title={activeLabel}>
{activeLabel}
</h2>
</div>
) : (
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
{/* Tag selector: Generale + phases */}
<select
value={currentEntityId}
onChange={(e) => setCurrentEntityId(e.target.value)}
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
>
{entityOptions.map((en) => (
<option key={en.id} value={en.id}>
{en.label}
</option>
))}
</select>
<div className="flex gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(e as unknown as React.FormEvent);
}
}}
placeholder="Scrivi un messaggio… (Invio per inviare)"
rows={2}
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
/>
<div className="flex shrink-0 items-center gap-1">
{/* Su mobile il pannello è già a piena larghezza: il tasto non avrebbe effetto. */}
<button
type="submit"
disabled={!body.trim()}
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
type="button"
onClick={toggleExpanded}
aria-label={expanded ? "Riduci la chat" : "Espandi a tutto schermo"}
className="hidden rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring sm:inline-flex"
>
Invia
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
type="button"
onClick={handleClose}
aria-label="Chiudi"
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<X className="h-5 w-5" />
</button>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
</form>
</div>
{/* Tab dei canali */}
{showTabs && (
<div className="shrink-0 border-b border-border bg-card">
<div
className="no-scrollbar mx-auto flex w-full gap-1 overflow-x-auto px-4 py-2"
style={expanded ? { maxWidth: "48rem" } : undefined}
role="tablist"
aria-label="Canali della conversazione"
>
{channels.map((channel) => {
const active = channel.key === activeChannel;
const unread = unreadChannels.has(channel.key);
return (
<button
key={channel.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveChannel(channel.key)}
title={channel.label}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring",
active
? "bg-muted font-semibold text-foreground"
: "font-medium text-muted-foreground hover:text-foreground"
)}
>
<span className="max-w-[110px] truncate">{channel.label}</span>
{unread && !active && (
<>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
<span className="sr-only">non letti</span>
</>
)}
</button>
);
})}
</div>
</div>
)}
{/* Feed */}
<div className="thin-scrollbar flex-1 overflow-y-auto">
{/* A tutto schermo si allarga il contenitore, non la misura del testo:
una riga larga 2000px non si legge. */}
<div
className="mx-auto w-full px-5 py-5"
style={expanded ? { maxWidth: "48rem" } : undefined}
>
{visibleMessages.length === 0 && visiblePending.length === 0 ? (
<p className="py-12 text-center text-sm italic text-muted-foreground">
Nessun messaggio in questo canale. Scrivi qui sotto per iniziare.
</p>
) : (
<MessageList
messages={visibleMessages}
index={index}
activeChannel={activeChannel}
clientId={clientId}
/>
)}
{visiblePending.map((item) => (
<div key={item.tempId} className="mt-5 flex gap-3">
<Avatar label="Tu" tone="client" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">Tu</span>
<span className="text-[10px] text-muted-foreground">
{item.failed ? "non inviato" : "invio…"}
</span>
</div>
<p
className={cn(
"mt-1 whitespace-pre-wrap text-sm leading-relaxed",
item.failed ? "text-destructive" : "text-muted-foreground"
)}
>
{item.body}
</p>
{item.failed && (
<div className="mt-1 flex gap-3">
<button
type="button"
onClick={() => retry(item)}
className="text-[11px] font-semibold text-primary hover:underline"
>
Riprova
</button>
<button
type="button"
onClick={() => discard(item.tempId)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Elimina
</button>
</div>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
</div>
{/* Composer */}
<div className="shrink-0 border-t border-border bg-card">
<div
className="mx-auto w-full px-4 py-3"
style={expanded ? { maxWidth: "48rem" } : undefined}
>
{preview ? (
<p className="py-2 text-center text-xs text-muted-foreground">
Composer disattivato in anteprima
</p>
) : (
<form onSubmit={handleSubmit}>
{/* Nessun selettore: il canale è il tab attivo. Prima andava
scelto due volte, qui e nella timeline. */}
<div className="rounded-xl border border-border bg-background transition-colors focus-within:border-primary">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
rows={2}
placeholder={`Scrivi in ${activeLabel}… (Invio per inviare)`}
aria-label={`Scrivi un messaggio in ${activeLabel}`}
className="w-full resize-none bg-transparent px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<div className="flex items-center justify-between border-t border-border-light px-3 py-2">
<span className="text-[10px] text-muted-foreground">
Shift + Invio per andare a capo
</span>
<button
type="submit"
disabled={!body.trim()}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus:outline-none focus:ring-2 focus:ring-ring"
>
<Send className="h-3.5 w-3.5" /> Invia
</button>
</div>
</div>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
</form>
)}
</div>
</div>
</div>
</>
);
}
// ── Feed ─────────────────────────────────────────────────────────────────────
function MessageList({
messages,
index,
activeChannel,
clientId,
}: {
messages: ChatMessage[];
index: ReturnType<typeof buildChannelIndex>;
activeChannel: string;
clientId: string;
}) {
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
// giro, che il compilatore React (giustamente) rifiuta.
const items = messages.map((m, i) => {
const at = toDate(m.created_at);
const prev = i > 0 ? messages[i - 1] : null;
const prevAt = prev ? toDate(prev.created_at) : null;
const showDivider = !prevAt || at.toDateString() !== prevAt.toDateString();
// Messaggi consecutivi dello stesso autore a breve distanza: si ripete solo
// il testo, non nome e avatar. Meno rumore, stessa informazione.
const grouped =
!showDivider &&
!!prev &&
m.author === prev.author &&
at.getTime() - (prevAt as Date).getTime() < GROUP_WINDOW_MS;
return { message: m, at, showDivider, grouped };
});
return (
<>
{items.map(({ message: m, at, showDivider, grouped }) => {
const isClient = m.author === "client";
const name = isClient ? "Tu" : "iamcavalli";
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
const entityLabel =
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined;
return (
<div key={m.id}>
{showDivider && (
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{formatDayDivider(at)}
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className={cn("flex gap-3", grouped ? "mt-1" : "mt-5")}>
{grouped ? (
<div className="w-9 shrink-0" aria-hidden="true" />
) : (
<Avatar label={name} tone={isClient ? "client" : "admin"} />
)}
<div className="min-w-0 flex-1">
{!grouped && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-foreground">{name}</span>
{entityLabel && (
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{entityLabel}
</span>
)}
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
</div>
)}
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{m.body}
</p>
</div>
</div>
</div>
);
})}
</>
);
}
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
return (
<span
aria-hidden="true"
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold",
tone === "client"
? "bg-accent text-accent-foreground"
: "bg-primary text-primary-foreground"
)}
>
{monogram(label)}
</span>
);
}
+44 -16
View File
@@ -1,15 +1,27 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import type { ClientView } from "@/lib/client-view";
/**
* Stato di sola UI della chat: quale canale è aperto, se il pannello è visibile,
* se è a tutto schermo. I messaggi NON stanno qui — vivono in ChatPanel, che è
* l'unico a leggerli, a fare polling e a tenerne la copia optimistic.
*
* Il canale sta invece nel context perché lo decide anche chi è fuori dal
* pannello: la bolla su una PhaseCard apre la chat già sulla fase giusta.
*/
interface ChatContextValue {
isOpen: boolean;
selectedPhaseId: string | null; // null = "Generale"
expanded: boolean;
/** Chiave del canale attivo: clientId per "Generale", phases.id per una fase. */
activeChannel: string;
phases: ClientView["phases"];
clientId: string;
openChat: (phaseId?: string) => void;
closeChat: () => void;
toggleExpanded: () => void;
setActiveChannel: (key: string) => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
@@ -30,20 +42,36 @@ export function ChatProvider({
clientId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
// di comments.entity_id, così non serve tradurre nulla in scrittura.
const [activeChannel, setActiveChannel] = useState<string>(clientId);
const openChat = useCallback((phaseId?: string) => {
setSelectedPhaseId(phaseId ?? null);
setIsOpen(true);
}, []);
const closeChat = useCallback(() => {
setIsOpen(false);
}, []);
return (
<ChatContext.Provider value={{ isOpen, selectedPhaseId, phases, clientId, openChat, closeChat }}>
{children}
</ChatContext.Provider>
const openChat = useCallback(
(phaseId?: string) => {
setActiveChannel(phaseId ?? clientId);
setIsOpen(true);
},
[clientId]
);
const closeChat = useCallback(() => setIsOpen(false), []);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const value = useMemo(
() => ({
isOpen,
expanded,
activeChannel,
phases,
clientId,
openChat,
closeChat,
toggleExpanded,
setActiveChannel,
}),
[isOpen, expanded, activeChannel, phases, clientId, openChat, closeChat, toggleExpanded]
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}