feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail

Tre cose che mancavano all'inbox admin, tutte senza migration.

1. Dall'inbox era impossibile aprire una conversazione: getConversations()
   costruiva la lista dai commenti, quindi un cliente compariva solo dopo aver
   scritto lui. Ora la lista parte da `clients` e i commenti la arricchiscono.
   Ordine: prima chi ha scritto (per recenza), in coda i clienti muti in
   alfabetico, cosi' l'inbox resta un inbox.

2. Menzioni «@Nome», rinviate dalla chat a canali. Modello senza schema: il tag
   si riconosce confrontando il testo con i nomi noti del cliente (nome intero,
   nome di battesimo, brand), insensibile ad accenti e maiuscole. Il body resta
   quello che l'admin ha scritto, quindi la menzione sopravvive alla modifica di
   un messaggio e resta leggibile ovunque finisca, mail compresa.
   Confini di parola su ENTRAMBI i lati: senza quello a sinistra,
   «scrivimi a mario@teckell.it» conteneva un tag «@Teckell».

3. Un tag manda una mail. E' l'unico messaggio che esce dal portale: per il
   resto il cliente entra quando gli pare, ma il tag e' la dichiarazione che
   quel messaggio non puo' aspettare il prossimo accesso. Nessuno scheduler --
   parte dalla stessa azione che scrive il messaggio, fuori transazione: se
   Resend e' giu' il messaggio in chat resta comunque scritto.
   Destinatari: whitelist OTP + email della scheda, deduplicati. Con zero
   indirizzi il compositore lo dice PRIMA, invece di lasciar credere che sia
   partita una mail che non partira'.

Il pulsante della mail punta a `?chat=<canale>`, validato lato server e passato
come prop: leggerlo nel browser vorrebbe dire renderizzare il pannello chiuso e
riaprirlo dopo l'idratazione.

La casella di risposta diventa controllata (ReplyComposer): il suggerimento del
tag deve leggere il testo mentre lo scrivi e reinserirlo al caret giusto.
Invio manda, Shift+Invio va a capo -- come nel pannello del cliente.

Verificato: `npm run build` e `eslint` puliti, parser delle menzioni provato su
9 casi. NON verificato a schermo ne' contro il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 21:51:58 +02:00
parent 817a8cd5d1
commit 41530b556a
14 changed files with 810 additions and 60 deletions
@@ -5,13 +5,13 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
import { SearchInput } from "@/components/ui/SearchInput";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MentionText } from "@/components/ui/MentionText";
import { cn } from "@/lib/utils";
import { ReplyComposer } from "./ReplyComposer";
import {
editConversationMessage,
markConversationRead,
replyToConversation,
} from "@/app/admin/conversazioni/actions";
import type {
ConversationSummary,
@@ -93,7 +93,7 @@ export function ConversationsView({
) : (
<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} />
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
</div>
)}
</section>
@@ -126,18 +126,28 @@ function ConversationListItem({
>
{conv.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
{conv.lastMessageAt && (
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
{conv.lastMessageAt ? (
<>
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
)}
{conv.lastMessage}
</>
) : (
<span className="italic text-muted-foreground/70">
Nessun messaggio scrivi tu per primo
</span>
)}
{conv.lastMessage}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<EntityBadge label={conv.lastEntityLabel} />
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
{conv.unread && (
<span className="flex items-center gap-1.5 shrink-0">
{conv.unreadCount > 1 && (
@@ -272,34 +282,29 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
{visibleMessages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
Nessun messaggio in questo canale.
Nessun messaggio in questo canale. Scrivi tu per primo.
</p>
) : (
visibleMessages.map((m) => (
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
<MessageBubble
key={m.id}
message={m}
clientId={thread.clientId}
mentionCandidates={thread.mentionCandidates}
/>
))
)}
</div>
{/* 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={`Rispondi in ${activeLabel}...`}
aria-label={`Rispondi nel canale ${activeLabel}`}
className="flex-1 resize-none"
/>
<Button type="submit" size="sm" className="shrink-0">
Invia
</Button>
</form>
<ReplyComposer
clientId={thread.clientId}
channelKey={activeChannel}
channelLabel={activeLabel}
isGeneral={isGeneral}
mentionCandidates={thread.mentionCandidates}
notifyEmailCount={thread.notifyEmailCount}
/>
</>
);
}
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
function MessageBubble({
message,
clientId,
mentionCandidates,
}: {
message: ConversationThread["messages"][number];
clientId: string;
mentionCandidates: string[];
}) {
const isAdmin = message.author === "admin";
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
@@ -391,7 +398,17 @@ function MessageBubble({
/>
) : (
<p className="text-xs leading-relaxed whitespace-pre-wrap">
{message.body}
<MentionText
body={message.body}
candidates={mentionCandidates}
// Sulla bolla admin il fondo è `bg-primary`: un chip tenue ci
// sparirebbe sopra, quindi si scava invece di colorare.
mentionClassName={
isAdmin
? "rounded bg-primary-foreground/20 px-1 font-semibold"
: undefined
}
/>
</p>
)}
@@ -0,0 +1,210 @@
"use client";
import { useMemo, useRef, useState, useTransition } from "react";
import { AtSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { replyToConversation } from "@/app/admin/conversazioni/actions";
/**
* Casella di risposta dell'inbox, con il tag del cliente.
*
* È controllata — e non più un `<form action>` con una textarea libera — perché
* il suggerimento del tag deve leggere il testo mentre lo scrivi e riscriverlo
* al punto giusto. Il canale continua a viaggiare col messaggio: `entity_id` è
* il canale aperto, `entity_type` si deriva da esso.
*/
export function ReplyComposer({
clientId,
channelKey,
channelLabel,
isGeneral,
mentionCandidates,
notifyEmailCount,
}: {
clientId: string;
channelKey: string;
channelLabel: string;
isGeneral: boolean;
/** I nomi con cui si può taggare questo cliente, dal più completo al più corto. */
mentionCandidates: string[];
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
notifyEmailCount: number;
}) {
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, startSending] = useTransition();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Posizione dell'ultima "@" ancora aperta e testo digitato dopo: null quando
// non c'è nessun tag in corso di scrittura.
const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);
const suggestions = useMemo(() => {
if (!trigger) return [];
const q = trigger.query.toLowerCase();
// Il nome intero per primo: è quello che si vuole quasi sempre.
return mentionCandidates.filter((c) => c.toLowerCase().startsWith(q)).slice(0, 4);
}, [trigger, mentionCandidates]);
const open = suggestions.length > 0;
function syncTrigger(value: string, caret: number) {
const before = value.slice(0, caret);
const at = before.lastIndexOf("@");
if (at === -1) return setTrigger(null);
// Una "@" vale solo a inizio parola: "email@dominio.it" non apre il menu.
const prev = at > 0 ? before[at - 1] : " ";
if (!/\s/.test(prev)) return setTrigger(null);
const query = before.slice(at + 1);
// Un tag non attraversa un a capo, e oltre il nome più lungo non c'è più
// niente da suggerire.
if (query.includes("\n")) return setTrigger(null);
setTrigger({ at, query });
}
function insert(candidate: string) {
if (!trigger) return;
const el = textareaRef.current;
const caret = el?.selectionStart ?? body.length;
const next = `${body.slice(0, trigger.at)}@${candidate} ${body.slice(caret)}`;
const nextCaret = trigger.at + candidate.length + 2;
setBody(next);
setTrigger(null);
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(nextCaret, nextCaret);
});
}
/** Il pulsante @: inserisce la chiocciola e apre subito il menu. */
function startMention() {
const el = textareaRef.current;
const caret = el?.selectionStart ?? body.length;
const needsSpace = caret > 0 && !/\s/.test(body[caret - 1]);
const prefix = needsSpace ? " @" : "@";
const next = `${body.slice(0, caret)}${prefix}${body.slice(caret)}`;
const nextCaret = caret + prefix.length;
setBody(next);
setTrigger({ at: nextCaret - 1, query: "" });
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(nextCaret, nextCaret);
});
}
function send() {
const text = body.trim();
if (!text || sending) return;
const fd = new FormData();
fd.set("entity_type", isGeneral ? "general" : "phase");
fd.set("entity_id", channelKey);
fd.set("body", text);
setError(null);
startSending(async () => {
try {
await replyToConversation(clientId, fd);
setBody("");
setTrigger(null);
} catch {
setError("Messaggio non inviato, riprova");
}
});
}
return (
<div className="border-t border-border bg-card p-4">
<div className="relative flex items-end gap-3">
{open && (
<ul
role="listbox"
aria-label="Tagga il cliente"
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
>
{suggestions.map((candidate, i) => (
<li key={candidate}>
<button
type="button"
role="option"
aria-selected={i === 0}
// onMouseDown e non onClick: il click toglierebbe il fuoco alla
// textarea prima di leggerne il caret, e l'inserimento finirebbe
// nel posto sbagliato.
onMouseDown={(e) => {
e.preventDefault();
insert(candidate);
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
i === 0 && "bg-muted/50"
)}
>
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{candidate}</span>
</button>
</li>
))}
</ul>
)}
<Textarea
ref={textareaRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
syncTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);
}}
onClick={(e) => {
const el = e.currentTarget;
syncTrigger(el.value, el.selectionStart ?? el.value.length);
}}
onKeyDown={(e) => {
if (open && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault();
insert(suggestions[0]);
return;
}
if (e.key === "Escape" && open) {
e.preventDefault();
setTrigger(null);
return;
}
// Invio manda, Shift+Invio va a capo: il comportamento che il
// pannello del cliente ha già.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
rows={2}
placeholder={`Rispondi in ${channelLabel}...`}
aria-label={`Rispondi nel canale ${channelLabel}`}
className="flex-1 resize-none"
/>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button type="button" size="sm" onClick={send} disabled={sending || !body.trim()}>
{sending ? "Invio…" : "Invia"}
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={startMention}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<AtSign className="h-3 w-3" /> Tagga
</button>
<span className="text-[11px] text-muted-foreground">
{notifyEmailCount > 0
? `Un tag gli manda una mail (${notifyEmailCount} ${notifyEmailCount === 1 ? "indirizzo" : "indirizzi"}).`
: "Nessuna email a registro: il tag resta solo in chat."}
</span>
{error && <span className="text-[11px] text-destructive">{error}</span>}
</div>
</div>
);
}
+4 -1
View File
@@ -58,7 +58,10 @@ export async function InboxBand() {
{conv.brand_name}
</h3>
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
{relativeTime(conv.lastMessageAt)}
{/* `unread` implica almeno un messaggio: il null non capita,
ma il tipo lo ammette da quando la lista include anche i
clienti con cui non si è ancora scritto. */}
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ""}
</span>
{conv.unreadCount > 1 && (
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
+21 -3
View File
@@ -1,4 +1,5 @@
import type { ClientView } from '@/lib/client-view';
import { mentionCandidates } from '@/lib/mentions';
import type { ChatData } from '@/lib/chat-channels';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
embedded?: boolean;
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
preview?: boolean;
/** Canale da aprire all'arrivo (`?chat=`), gia' validato dalla pagina. */
initialChatChannel?: string | null;
}
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
export function ClientDashboard({
view,
token,
chat,
embedded = false,
preview = false,
initialChatChannel = null,
}: 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
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
return (
<PreviewProvider preview={preview}>
<ChatProvider phases={view.phases} clientId={view.client.id}>
<ChatProvider
phases={view.phases}
clientId={view.client.id}
initialChannel={initialChatChannel}
>
<div className={embedded ? "" : "min-h-screen bg-background"}>
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
{!embedded && (
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
</footer>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} chat={chat} />
<ChatPanel
token={token}
chat={chat}
mentionCandidates={mentionCandidates(view.client)}
/>
</div>
</ChatProvider>
</PreviewProvider>
+21 -5
View File
@@ -10,12 +10,13 @@ import {
type ChatData,
type ChatMessage,
} from "@/lib/chat-channels";
import { MentionText } from "@/components/ui/MentionText";
import { useChatContext } from "./ChatProvider";
import { usePreview } from "./PreviewProvider";
// 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'.
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
const POLL_MS = 20_000;
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
@@ -60,7 +61,16 @@ function monogram(label: string): string {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
export function ChatPanel({
token,
chat,
mentionCandidates,
}: {
token: string;
chat: ChatData;
/** I nomi con cui l'admin può taggare questo cliente — vedi lib/mentions.ts. */
mentionCandidates: string[];
}) {
const {
isOpen,
expanded,
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
clientId={clientId}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
mentionCandidates={mentionCandidates}
canEdit={!preview}
onEdit={applyEdit}
/>
@@ -605,6 +616,7 @@ function MessageList({
clientId,
adminName,
adminAvatarUrl,
mentionCandidates,
canEdit,
onEdit,
}: {
@@ -614,6 +626,7 @@ function MessageList({
clientId: string;
adminName: string;
adminAvatarUrl: string | null;
mentionCandidates: string[];
/** Falso in anteprima admin: si guarda, non si scrive. */
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
@@ -647,6 +660,7 @@ function MessageList({
grouped={grouped}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
mentionCandidates={mentionCandidates}
// 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.
entityLabel={
@@ -676,6 +690,7 @@ function MessageRow({
entityLabel,
adminName,
adminAvatarUrl,
mentionCandidates,
canEdit,
onEdit,
}: {
@@ -686,6 +701,7 @@ function MessageRow({
entityLabel?: string;
adminName: string;
adminAvatarUrl: string | null;
mentionCandidates: string[];
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
}) {
@@ -803,7 +819,7 @@ function MessageRow({
) : (
<>
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{m.body}
<MentionText body={m.body} candidates={mentionCandidates} />
{m.edited_at && (
<span className="ml-1.5 text-[10px] text-muted-foreground">
(modificato)
+17 -2
View File
@@ -36,16 +36,31 @@ export function ChatProvider({
children,
phases,
clientId,
initialChannel,
}: {
children: React.ReactNode;
phases: ClientView["phases"];
clientId: string;
/**
* Canale richiesto da fuori con `?chat=<key>` — è così che il pulsante della
* mail di tag porta il cliente dentro la conversazione giusta invece che sulla
* dashboard, dove la chat resterebbe chiusa e il messaggio non letto.
*
* Arriva dal server, già validato: leggerlo qui da `window.location` vorrebbe
* dire aprire il pannello in un effetto, cioè renderizzarlo chiuso e riaprirlo
* subito dopo l'idratazione.
*/
initialChannel?: string | null;
}) {
const [isOpen, setIsOpen] = useState(false);
// Un canale richiesto vale anche come "apri il pannello": chi arriva da quel
// link sta venendo a leggere un messaggio, non a guardare la timeline.
const [isOpen, setIsOpen] = useState(!!initialChannel);
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 [activeChannel, setActiveChannel] = useState<string>(
initialChannel ?? clientId
);
const openChat = useCallback(
(phaseId?: string) => {
+44
View File
@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { cn } from "@/lib/utils";
import { splitMentions } from "@/lib/mentions";
/**
* Corpo di un messaggio di chat con le menzioni evidenziate.
*
* Sostituisce `{m.body}` dentro il paragrafo: mantiene `whitespace-pre-wrap` sul
* genitore, quindi gli a capo continuano a funzionare senza dover spezzare il
* testo in righe qui dentro.
*/
export function MentionText({
body,
candidates,
mentionClassName,
}: {
body: string;
candidates: string[];
/** Il contrasto cambia col fondo della bolla: lo decide chi la disegna. */
mentionClassName?: string;
}) {
const parts = splitMentions(body, candidates);
return (
<>
{parts.map((part, i) =>
part.type === "mention" ? (
<span
key={i}
className={cn(
"rounded px-1 font-semibold",
mentionClassName ??
"bg-primary/10 text-primary dark:bg-primary/20"
)}
>
{part.value}
</span>
) : (
<Fragment key={i}>{part.value}</Fragment>
)
)}
</>
);
}