feat(21-22): agente AI generazione preventivo + pagina pubblica deck

- Migration 0010: tabella proposals (id, slug, lead_id, client_id,
  offer_macro_id, content jsonb, state, selected_tier, accepted_at)
  applicata a prod via SSH tunnel
- @anthropic-ai/sdk@0.105.0 installato; ANTHROPIC_API_KEY in .env.local
- src/lib/proposal/: schema Zod ProposalContent, agente Claude Opus 4.8,
  assemble (AI + offerta DB + config consulente), queries, profile.ts
- Admin: /admin/preventivi lista + /genera (pre-fill ?lead_id=X) + /[id] review
- Sidebar: voce Preventivi + CTA globale lime "Genera preventivo"
- LeadDetail: pulsante "Genera preventivo" → /admin/preventivi/genera?lead_id=X
- Pagina pubblica /preventivo/[slug]: deck 20+ slide light-mode iamcavalli,
  navigazione frecce + dot + keyboard, accept/reject con guard immutabilità
- STATE.md aggiornato (80%), 21-PLAN.md scritto nel formato GSD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 14:37:05 +02:00
parent 86c86cd420
commit fdcc938252
41 changed files with 2818 additions and 19 deletions
+160
View File
@@ -0,0 +1,160 @@
import Anthropic from "@anthropic-ai/sdk";
import { ProposalContentSchema, type ProposalContent } from "./schema";
import type { OfferEditorData } from "@/lib/offer-queries";
import type { Client, Lead, ClientTranscript } from "@/db/schema";
const MODEL = "claude-opus-4-8";
type AgentInput = {
// lead o cliente (uno dei due)
lead?: Pick<Lead, "id" | "name" | "email" | "company" | "notes">;
client?: Pick<Client, "id" | "name" | "brand_name" | "brief">;
transcripts: Pick<ClientTranscript, "title" | "content" | "call_date">[];
offer: OfferEditorData;
};
function buildSystemPrompt(): string {
return `Sei un consulente senior di brand e business strategy con 10+ anni di esperienza.
Il tuo compito è analizzare i transcript di call discovery con un cliente e generare il
contenuto di un preventivo professionale in italiano.
REGOLE FONDAMENTALI:
- Usa SOLO informazioni reali estratte dai transcript. Non inventare problemi, soluzioni o citazioni.
- Le citazioni (quote) nei nodi problema devono essere verbatim — parole esatte dette dal cliente.
- I problemi devono emergere dalle parole del cliente, non da assunzioni tue.
- Le soluzioni devono specchiare i problemi (stessa sequenza 0105) e descrivere la trasformazione concreta.
- Il tono è professionale ma diretto, mai generico. Usa il lessico del settore del cliente.
- Non includere prezzi o importi nel contenuto generato — quelli vengono dal DB dell'offerta.
- Rispondi SOLO con JSON valido, nessun testo extra prima o dopo.`;
}
function buildUserPrompt(input: AgentInput): string {
const subject = input.client
? `Cliente: ${input.client.name} (brand: ${input.client.brand_name})\nBrief: ${input.client.brief}`
: `Lead: ${input.lead?.name}${input.lead?.company ? `${input.lead.company}` : ""}${input.lead?.notes ? `\nNote: ${input.lead.notes}` : ""}`;
const transcriptBlocks = input.transcripts
.map(
(t, i) =>
`=== TRANSCRIPT ${i + 1}${t.call_date}${t.title ? ` (${t.title})` : ""} ===\n${t.content}`
)
.join("\n\n");
const offerDescription = `Offerta: ${input.offer.macro.public_name}
Descrizione: ${input.offer.macro.description ?? "—"}
Promessa di trasformazione: ${input.offer.macro.transformation_promise ?? "—"}
Tier disponibili: ${input.offer.tiers.map((t) => `${t.tier_letter} (${t.public_name})`).join(", ")}`;
return `${subject}
${offerDescription}
TRANSCRIPT DELLE CALL:
${transcriptBlocks || "(nessun transcript disponibile — genera contenuto basato sul brief)"}
Genera il contenuto del preventivo come JSON. Segui ESATTAMENTE questo schema TypeScript:
{
vision: { headline: string, body: string },
problems: Array<{
id: string, // "01" | "02" | ... | "05"
title: string, // titolo della slide problema
subtitle: string, // es. "LA VOCE" — tema macro
body: string, // 2-3 frasi descrittive
risk: string, // testo box "IL RISCHIO" — conseguenza se non si risolve
quote: string, // citazione verbatim dal transcript
quoteAttribution: string // "NOME · DISCOVERY YYYY-MM-DD"
}>, // 35 problemi
problemSynthesis: {
rootCause: string, // la domanda strategica comune
nodeLabels: string[] // 5 label brevi per il diagramma
},
solutions: Array<{
id: string, // specchia il problem node
title: string,
subtitle: string, // es. "SOLUZIONE 01 · LA VOCE"
transformation: string, // testo box "LA TRASFORMAZIONE"
throughWhat: string[] // 3 bullet "ATTRAVERSO COSA"
}>,
solutionSynthesis: {
direction: string,
elevations: Array<{ id: string, label: string }> // 5 elementi
},
scope: {
scopeTitle: string,
scopeBody: string,
objectives: string[] // 35 obiettivi misurabili
},
deliverables: {
deliverables: string[], // output principali (tier-agnostic)
outOfScope: string[] // 35 elementi esclusi
},
timeline: {
totalWeeks: number,
phases: Array<{
label: string,
startWeek: number,
endWeek: number,
color: "cyan" | "purple" | "green"
}>,
disclaimer: string
},
stagesRecap: {
stages: Array<{
number: string, // "01" | "02" | "03"
tierLetter: string, // "A" | "B" | "C"
label: string, // es. "OPZIONE A AUDIT"
headline: string, // es. "Si capisce."
body: string,
deliverable: string
}>
},
comparisonMatrix: Array<{
component: string,
description: string,
inA: boolean,
inB: boolean,
inC: boolean
}> // 410 righe
}`;
}
export async function generateProposalContent(
input: AgentInput
): Promise<{ content: ProposalContent; rawJson: string }> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY non configurata");
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model: MODEL,
max_tokens: 8192,
system: buildSystemPrompt(),
messages: [{ role: "user", content: buildUserPrompt(input) }],
});
const rawText =
message.content[0].type === "text" ? message.content[0].text : "";
// Estrae il JSON dalla risposta (gestisce eventuale wrapping in ```)
const jsonMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ||
rawText.match(/(\{[\s\S]*\})/);
const jsonStr = jsonMatch ? jsonMatch[1] : rawText;
let parsed: unknown;
try {
parsed = JSON.parse(jsonStr);
} catch {
throw new Error(`L'AI ha prodotto JSON non valido: ${rawText.slice(0, 200)}`);
}
const result = ProposalContentSchema.safeParse(parsed);
if (!result.success) {
throw new Error(
`Contenuto AI non valido: ${result.error.issues.map((i) => i.message).join("; ")}`
);
}
return { content: result.data, rawJson: jsonStr };
}
+105
View File
@@ -0,0 +1,105 @@
import type { ProposalContent } from "./schema";
import type { OfferEditorData } from "@/lib/offer-queries";
import { CONSULTANT_PROFILE } from "./profile";
import type { Client, Lead } from "@/db/schema";
// Struttura completa salvata in proposals.content (jsonb)
export type AssembledProposal = {
// Metadati di generazione
generatedAt: string; // ISO string
model: string;
// Intestazione (cover + vision)
header: {
clientName: string; // es. "Yeldo"
consultantName: string;
offerTitle: string;
offerDescription: string | null;
date: string; // "DD MMM YYYY" formattata in italiano
validityDays: number;
};
// Sezioni generate dall'AI
ai: ProposalContent;
// Profilo consulente (snapshot al momento della generazione)
consultant: typeof CONSULTANT_PROFILE;
// Dati offerta dal DB (snapshot — prezzi al momento della generazione)
offer: {
macroId: string;
publicName: string;
tiers: Array<{
id: string;
tierLetter: string;
publicName: string;
publicPrice: string | null;
servicesTotal: string;
durationMonths: number;
services: Array<{ id: string; name: string; unitPrice: string }>;
}>;
};
};
type AssembleInput = {
lead?: Pick<Lead, "id" | "name" | "company">;
client?: Pick<Client, "id" | "name" | "brand_name">;
offer: OfferEditorData;
aiContent: ProposalContent;
model: string;
};
export function assembleProposal(input: AssembleInput): AssembledProposal {
const clientName =
input.client?.brand_name ?? input.lead?.company ?? input.lead?.name ?? "Cliente";
const offerTitle = input.offer.macro.public_name;
// Formatta data in italiano
const now = new Date();
const date = now.toLocaleDateString("it-IT", {
day: "2-digit",
month: "long",
year: "numeric",
});
// Snapshot servizi per tier (solo quelli assegnati)
const tiers = input.offer.tiers.map((tier) => {
const assignedServices = input.offer.availableServices.filter((s) =>
tier.assignedServiceIds.includes(s.id)
);
return {
id: tier.id,
tierLetter: tier.tier_letter ?? "",
publicName: tier.public_name,
publicPrice: tier.public_price,
servicesTotal: tier.servicesTotal,
durationMonths: tier.duration_months,
services: assignedServices.map((s) => ({
id: s.id,
name: s.name,
unitPrice: s.unit_price,
})),
};
});
return {
generatedAt: now.toISOString(),
model: input.model,
header: {
clientName,
consultantName: CONSULTANT_PROFILE.name,
offerTitle,
offerDescription: input.offer.macro.description ?? null,
date,
validityDays: CONSULTANT_PROFILE.legal.validityDays,
},
ai: input.aiContent,
consultant: CONSULTANT_PROFILE,
offer: {
macroId: input.offer.macro.id,
publicName: offerTitle,
tiers,
},
};
}
+132
View File
@@ -0,0 +1,132 @@
// Profilo consulente iamcavalli — fonte di verità per tutte le proposte.
// Editing = modifica diretta di questo file (v1, nessuna UI di gestione).
// Foto e immagini via URL esterno (no file hosting v1).
export type Testimonial = {
name: string;
role: string;
quote: string;
photoUrl?: string; // URL esterno, opzionale
};
export type ConsultantFact = {
value: string; // es. "20+"
label: string; // es. "ANNI DI ESPERIENZA"
description: string;
};
export type NextStep = {
number: string; // "01" | "02" | "03"
text: string;
highlightedPart?: string; // parola/data da evidenziare in verde
};
export type ConsultantProfile = {
name: string;
title: string;
bio: string;
photoUrl?: string;
credentials: string[];
facts: [ConsultantFact, ConsultantFact, ConsultantFact]; // esattamente 3
contact: {
email: string;
phone?: string;
website?: string;
};
// Boilerplate legale e termini — appaiono nella slide "Come si parte"
legal: {
revisions: string;
ip: string;
nda: string;
forum: string;
validityDays: number;
acceptanceInstructions: string;
};
// Passi "Come procedere" (slide finale)
nextSteps: [NextStep, NextStep, NextStep];
// Testimonial grid (slide 0709) — mostra tutte, la pagina le impagina in griglie da 9
testimonials: Testimonial[];
};
export const CONSULTANT_PROFILE: ConsultantProfile = {
name: "Simone Cavalli",
title: "Strategist · Brand & Business",
bio: "Consulente di personal branding con oltre 10 anni di esperienza nell'aiutare professionisti e aziende a costruire una presenza autorevole e un posizionamento distintivo. Lavoro con founder, manager e liberi professionisti che vogliono trasformare la loro competenza in un brand riconoscibile.",
photoUrl: undefined, // → aggiungere URL esterno (es. CDN o Google Drive public)
credentials: [
"Fondatore di iamcavalli — consulenza di personal branding",
"Autore di percorsi formativi su posizionamento e comunicazione",
"Ha lavorato con oltre 100 professionisti in Italia",
// → aggiungere altre credenziali reali
],
facts: [
{
value: "10+",
label: "ANNI DI ESPERIENZA",
description:
"Anni di consulenza strategica su brand personale e business: formazione, consulenza diretta a professionisti e team in scaling.",
},
{
value: "100+",
label: "PROFESSIONISTI SEGUITI",
description:
"Founder, manager e liberi professionisti trasformati in brand riconoscibili nel loro settore.",
},
{
value: "3",
label: "PILASTRI DEL METODO",
description:
"Posizionamento, Comunicazione, Sistema: il percorso strutturato che porta da 'bravo a fare' a 'riconosciuto come il migliore'.",
},
],
contact: {
email: "simone@iamcavalli.com", // → verificare email pubblica corretta
phone: undefined, // → aggiungere se necessario
website: "iamcavalli.com",
},
legal: {
revisions: "Revisioni illimitate entro lo scope concordato.",
ip: "Diritti di proprietà intellettuale trasferiti al cliente alla consegna finale.",
nda: "NDA reciproco standard a copertura del progetto.",
forum: "Foro competente Milano.",
validityDays: 14,
acceptanceInstructions:
"Email di accettazione formale con indicazione del tier scelto. La mail vale come accordo commerciale e attiva la tranche iniziale di pagamento.",
},
nextSteps: [
{
number: "01",
text: "Conferma del prezzo bloccato entro 7 giorni dall'invio per garantire il pricing proposto.",
highlightedPart: undefined, // → sarà calcolato dinamicamente dalla data di pubblicazione
},
{
number: "02",
text: "Accettazione formale del tier scelto via email entro la scadenza indicata.",
highlightedPart: undefined,
},
{
number: "03",
text: "Se utile prima di scegliere, una call di Q&A (30 min) per chiarimenti sulla proposta e definizione della data di inizio lavori.",
highlightedPart: "call di Q&A",
},
],
testimonials: [
// → Aggiungere testimonianze reali. Formato:
// { name: "Nome Cognome", role: "Professione", quote: "Citazione..." }
{
name: "Cliente 1",
role: "Professione",
quote: "→ Aggiungere testimonianza reale.",
},
{
name: "Cliente 2",
role: "Professione",
quote: "→ Aggiungere testimonianza reale.",
},
{
name: "Cliente 3",
role: "Professione",
quote: "→ Aggiungere testimonianza reale.",
},
],
};
+117
View File
@@ -0,0 +1,117 @@
import { eq, desc } from "drizzle-orm";
import { db } from "@/db";
import { proposals, leads, clients, offer_macros } from "@/db/schema";
import type { AssembledProposal } from "./assemble";
export type ProposalListItem = {
id: string;
slug: string;
title: string | null;
state: string;
leadId: string | null;
leadName: string | null;
clientId: string | null;
clientName: string | null;
offerMacroId: string;
offerName: string;
createdAt: Date;
updatedAt: Date;
};
export async function listProposals(): Promise<ProposalListItem[]> {
const rows = await db
.select({
id: proposals.id,
slug: proposals.slug,
title: proposals.title,
state: proposals.state,
leadId: proposals.lead_id,
leadName: leads.name,
clientId: proposals.client_id,
clientName: clients.name,
offerMacroId: proposals.offer_macro_id,
offerName: offer_macros.public_name,
createdAt: proposals.created_at,
updatedAt: proposals.updated_at,
})
.from(proposals)
.leftJoin(leads, eq(proposals.lead_id, leads.id))
.leftJoin(clients, eq(proposals.client_id, clients.id))
.leftJoin(offer_macros, eq(proposals.offer_macro_id, offer_macros.id))
.orderBy(desc(proposals.created_at));
return rows.map((r) => ({
id: r.id,
slug: r.slug,
title: r.title,
state: r.state,
leadId: r.leadId,
leadName: r.leadName,
clientId: r.clientId,
clientName: r.clientName,
offerMacroId: r.offerMacroId,
offerName: r.offerName ?? "",
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
}
export type ProposalDetail = {
id: string;
slug: string;
title: string | null;
state: string;
leadId: string | null;
clientId: string | null;
offerMacroId: string;
model: string | null;
selectedTier: string | null;
acceptedAt: Date | null;
clientEmail: string | null;
clientNotes: string | null;
content: AssembledProposal;
createdAt: Date;
updatedAt: Date;
};
export async function getProposalById(id: string): Promise<ProposalDetail | null> {
const [row] = await db
.select()
.from(proposals)
.where(eq(proposals.id, id))
.limit(1);
if (!row) return null;
return mapRow(row);
}
export async function getProposalBySlug(slug: string): Promise<ProposalDetail | null> {
const [row] = await db
.select()
.from(proposals)
.where(eq(proposals.slug, slug))
.limit(1);
if (!row) return null;
return mapRow(row);
}
function mapRow(row: typeof proposals.$inferSelect): ProposalDetail {
return {
id: row.id,
slug: row.slug,
title: row.title,
state: row.state,
leadId: row.lead_id,
clientId: row.client_id,
offerMacroId: row.offer_macro_id,
model: row.model,
selectedTier: row.selected_tier,
acceptedAt: row.accepted_at,
clientEmail: row.client_email,
clientNotes: row.client_notes,
content: row.content as AssembledProposal,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
// ── Schema delle sezioni generate dall'AI ──────────────────────────────────
// Le sezioni da OFFER DB (pricing tier) e CONFIG (bio/testimonianze) NON
// sono prodotte dall'AI e non fanno parte di questo schema.
export const ProblemNodeSchema = z.object({
id: z.string(), // "01" | "02" | ... | "05"
title: z.string(), // es. "La voce non è una. Sono tre."
subtitle: z.string(), // es. "LA VOCE"
body: z.string(), // paragrafo descrittivo
risk: z.string(), // testo del box "IL RISCHIO"
quote: z.string(), // citazione verbatim dal transcript
quoteAttribution: z.string(), // "NOME COGNOME · DISCOVERY YYYY-MM-DD"
});
export const SolutionNodeSchema = z.object({
id: z.string(), // specchia il problem node
title: z.string(), // es. "Tre voci coordinate, un solo brand."
subtitle: z.string(), // es. "SOLUZIONE 01 · LA VOCE"
transformation: z.string(), // testo del box "LA TRASFORMAZIONE"
throughWhat: z.array(z.string()), // bullet "ATTRAVERSO COSA" (3 voci)
});
export const DiagramNodeSchema = z.object({
id: z.string(),
label: z.string(),
});
export const ScopeSectionSchema = z.object({
scopeTitle: z.string(),
scopeBody: z.string(),
objectives: z.array(z.string()), // 35 bullet con "checkmark" visivo
});
export const DeliverablesSectionSchema = z.object({
deliverables: z.array(z.string()), // elenco output finali (tier-agnostic, macro)
outOfScope: z.array(z.string()), // ciò che NON è incluso
});
export const TimelineSectionSchema = z.object({
totalWeeks: z.number(),
phases: z.array(z.object({
label: z.string(), // es. "Audit Strategico"
startWeek: z.number(),
endWeek: z.number(),
color: z.enum(["cyan", "purple", "green"]), // palette fissa light-mode
})),
disclaimer: z.string(),
});
export const StagesRecapSchema = z.object({
stages: z.array(z.object({
number: z.string(), // "01" | "02" | "03"
tierLetter: z.string(), // "A" | "B" | "C"
label: z.string(), // es. "OPZIONE A AUDIT"
headline: z.string(), // es. "Si capisce."
body: z.string(),
deliverable: z.string(), // es. "Audit decision document"
})),
});
export const ComparisonRowSchema = z.object({
component: z.string(), // es. "Brand Architecture System"
description: z.string(), // sottotitolo
inA: z.boolean(),
inB: z.boolean(),
inC: z.boolean(),
});
// Root schema — contenuto generato dall'AI
export const ProposalContentSchema = z.object({
// Slide "dove andiamo" (02)
vision: z.object({
headline: z.string(), // es. "Tre voci. Un solo brand."
body: z.string(), // paragrafo breve
}),
// Cap.02 — 5 nodi problema estratti dai transcript
problems: z.array(ProblemNodeSchema).min(3).max(5),
// Sintesi problema (5 nodi → 1 radice)
problemSynthesis: z.object({
rootCause: z.string(), // la domanda strategica non ancora risolta
nodeLabels: z.array(z.string()).length(5), // label per il diagramma
}),
// Cap.03 — soluzioni mirror
solutions: z.array(SolutionNodeSchema).min(3).max(5),
// Sintesi soluzione (1 direzione → 5 elevazioni)
solutionSynthesis: z.object({
direction: z.string(),
elevations: z.array(DiagramNodeSchema).length(5),
}),
// Cap.04 — scope, deliverable, timeline
scope: ScopeSectionSchema,
deliverables: DeliverablesSectionSchema,
timeline: TimelineSectionSchema,
// Slide di recap (3 tappe progressive)
stagesRecap: StagesRecapSchema,
// Tabella comparativa componenti × A/B/C
comparisonMatrix: z.array(ComparisonRowSchema).min(4).max(10),
});
export type ProposalContent = z.infer<typeof ProposalContentSchema>;
export type ProblemNode = z.infer<typeof ProblemNodeSchema>;
export type SolutionNode = z.infer<typeof SolutionNodeSchema>;
export type ComparisonRow = z.infer<typeof ComparisonRowSchema>;