41530b556a
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>
97 lines
4.1 KiB
TypeScript
97 lines
4.1 KiB
TypeScript
import { getConversations } from "@/lib/conversations-queries";
|
|
import { relativeTime } from "@/lib/dates";
|
|
import Link from "next/link";
|
|
|
|
const MAX_ROWS = 6;
|
|
|
|
/**
|
|
* I messaggi che aspettano una risposta, in cima alla dashboard.
|
|
*
|
|
* Prima era un riquadro stretto in fondo alla terza colonna: c'era, ma sotto la
|
|
* piega. Un messaggio non letto è la cosa più urgente della giornata e va vista
|
|
* per prima — per questo occupa tutta la larghezza, e per questo sparisce del
|
|
* tutto quando non c'è niente da leggere invece di lasciare a video una fascia
|
|
* vuota che si impara a saltare.
|
|
*
|
|
* Si legge qui, si risponde in /admin/conversazioni.
|
|
*/
|
|
export async function InboxBand() {
|
|
const conversations = await getConversations();
|
|
const unread = conversations.filter((c) => c.unread);
|
|
|
|
if (unread.length === 0) return null;
|
|
|
|
const visible = unread.slice(0, MAX_ROWS);
|
|
const totalMessages = unread.reduce((sum, c) => sum + c.unreadCount, 0);
|
|
|
|
return (
|
|
<section className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
|
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
Da rispondere
|
|
</h2>
|
|
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
|
|
{totalMessages} {totalMessages === 1 ? "messaggio" : "messaggi"} ·{" "}
|
|
{unread.length} {unread.length === 1 ? "cliente" : "clienti"}
|
|
</span>
|
|
</div>
|
|
<Link
|
|
href="/admin/conversazioni"
|
|
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
|
|
>
|
|
Apri conversazioni →
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
{visible.map((conv) => (
|
|
<Link
|
|
key={conv.clientId}
|
|
href={`/admin/conversazioni?c=${conv.clientId}`}
|
|
className="flex items-start justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex items-baseline gap-2 flex-wrap">
|
|
<h3 className="text-xs font-bold text-foreground truncate">
|
|
{conv.brand_name}
|
|
</h3>
|
|
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
|
{/* `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">
|
|
{conv.unreadCount} nuovi
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-1 line-clamp-2">
|
|
{conv.lastMessage}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground/70 mt-1 truncate">
|
|
{conv.lastEntityLabel}
|
|
</p>
|
|
</div>
|
|
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 mt-0.5">
|
|
Rispondi →
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{unread.length > MAX_ROWS && (
|
|
<Link
|
|
href="/admin/conversazioni"
|
|
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
|
|
>
|
|
Altri {unread.length - MAX_ROWS} clienti in attesa
|
|
</Link>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|