Files
clienthub/src/app/client/[token]/page.tsx
T
simone 41530b556a 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>
2026-08-31 21:51:58 +02:00

250 lines
8.8 KiB
TypeScript

import { cache } from "react";
import { notFound } from "next/navigation";
import {
getClientWithProjectsByToken,
getProjectView,
type ProjectView,
type ClientView,
type ClientProjectSummary,
} from "@/lib/client-view";
import { getClientGate } from "@/lib/client-gate";
import { ClientDashboard } from "@/components/client-dashboard";
import { OtpGate } from "@/components/client/OtpGate";
import { PreviewBanner } from "@/components/client/PreviewBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { normalizeTaskStatus } from "@/lib/task-status";
import { getAdminIdentity } from "@/lib/settings";
export const revalidate = 0;
const getCachedClientData = cache(getClientWithProjectsByToken);
// Adapter: converts ProjectView + client info into ClientView shape for ClientDashboard reuse
function projectViewToClientView(
client: ClientProjectSummary["client"],
view: ProjectView
): ClientView {
return {
client: {
id: view.project.client_id,
name: client.name,
brand_name: client.brand_name,
brief: "",
accepted_total: view.project.accepted_total,
},
phases: view.phases.map((phase) => ({
id: phase.id,
title: phase.title,
status: phase.status as "upcoming" | "active" | "done",
sort_order: phase.sort_order,
tasks: phase.tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
status: normalizeTaskStatus(task.status),
sort_order: task.sort_order,
deliverables: task.deliverables.map((d) => ({
id: d.id,
title: d.title,
url: d.url,
status: d.status as "pending" | "submitted" | "approved",
// approved_at is immutable once set — CLAUDE.md constraint LOCKED
approved_at: d.approved_at instanceof Date ? d.approved_at.toISOString() : null,
})),
})),
progress_pct: phase.progress_pct,
})),
payments: view.payments.map((p) => ({
id: p.id,
label: p.label,
status: p.status as "da_saldare" | "inviata" | "saldato",
due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null,
paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null,
})),
documents: view.documents.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
})),
notes: view.notes.map((n) => ({
id: n.id,
body: n.body,
created_at: n.created_at instanceof Date ? n.created_at.toISOString() : String(n.created_at),
})),
global_progress_pct: view.global_progress_pct,
activeOffers: view.activeOffers,
transcripts: view.transcripts.map((t) => ({
id: t.id,
title: t.title,
call_date: t.call_date,
content: t.content,
created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at),
})),
};
}
export async function generateMetadata({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
console.log("[generateMetadata] token:", token);
const clientData = await getCachedClientData(token);
if (!clientData) return { title: "Not Found" };
return {
title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`,
description: "Dashboard stato progetto",
};
}
/**
* Il canale chiesto con `?chat=<key>`, se appartiene a QUESTO progetto.
*
* La validazione non e' cosmetica: senza, un id qualsiasi nella query aprirebbe
* la chat su un canale vuoto — e con piu' progetti a tab, la fase di un progetto
* spalancherebbe il pannello anche negli altri.
*/
function resolveChatChannel(
requested: string | undefined,
view: ProjectView
): string | null {
if (!requested) return null;
if (requested === view.project.client_id) return requested;
return view.phases.some((p) => p.id === requested) ? requested : null;
}
export default async function ClientPage({
params,
searchParams,
}: {
params: Promise<{ token: string }>;
searchParams: Promise<{ preview?: string; chat?: string }>;
}) {
const { token } = await params;
const { preview: previewParam, chat: chatParam } = await searchParams;
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
// dell'HTML anche se non compaiono a schermo. Vedi src/lib/client-gate.ts.
const { client: identity, session, preview } = await getClientGate(token, {
previewRequested: previewParam === "1",
});
if (identity && !session && !preview) {
return <OtpGate token={token} brandName={identity.brand_name} />;
}
const clientData = await getCachedClientData(token);
if (!clientData) notFound();
const { client, projects } = clientData;
const banner = preview ? <PreviewBanner brandName={client.brand_name} /> : null;
if (projects.length === 0) {
return (
<>
{banner}
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
</div>
</div>
</>
);
}
// Come si firma chi risponde in chat. Una lettura sola per pagina, condivisa
// da tutti i progetti: è la stessa persona in ogni tab.
const admin = await getAdminIdentity();
if (projects.length === 1) {
// D-09: single project → direct view without selector
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return (
<>
{banner}
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
chat={{
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
adminName: admin.name,
adminAvatarUrl: admin.avatarUrl,
}}
preview={preview}
initialChatChannel={resolveChatChannel(chatParam, view)}
/>
</>
);
}
// D-10: 2+ projects → tabs with project names
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
return (
<div className="min-h-screen bg-background">
{banner}
<header className="sticky top-0 z-50 flex flex-col items-center gap-4 border-b border-border-light bg-card px-6 py-5 shadow-card md:flex-row md:justify-between md:px-8">
<div className="flex w-full items-center gap-3 md:w-auto">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">iamcavalli</span>
<span className="text-border">|</span>
<span className="text-xs font-medium text-muted-foreground">Client Portal</span>
</div>
<div className="text-center">
<h1 className="text-xl font-bold tracking-tight text-foreground">{client.brand_name}</h1>
</div>
<div className="hidden items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400 md:flex">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
Area Riservata Protetta
</div>
</header>
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 py-8">
<Tabs defaultValue={projects[0].id} className="w-full">
<TabsList className="mb-6">
{projects.map((p) => (
<TabsTrigger key={p.id} value={p.id}>
{p.name}
</TabsTrigger>
))}
</TabsList>
{projects.map((p, i) => {
const view = projectViews[i];
return (
<TabsContent key={p.id} value={p.id}>
{view ? (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
chat={{
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
adminName: admin.name,
adminAvatarUrl: admin.avatarUrl,
}}
embedded
preview={preview}
initialChatChannel={resolveChatChannel(chatParam, view)}
/>
) : (
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
)}
</TabsContent>
);
})}
</Tabs>
</div>
<footer className="mt-10 py-10 text-center text-xs text-muted-foreground">
Questa è la tua dashboard privata non condividere il link.
</footer>
</div>
);
}