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>
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
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;
|
|
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);
|
|
|
|
export function useChatContext() {
|
|
const ctx = useContext(ChatContext);
|
|
if (!ctx) throw new Error("useChatContext must be used inside ChatProvider");
|
|
return ctx;
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
// 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>(
|
|
initialChannel ?? clientId
|
|
);
|
|
|
|
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>;
|
|
}
|