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
+11 -11
View File
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v2.2
milestone_name: — Sales Loop
status: executing
stopped_at: "Phase 19 completa (19-01 done). Pronta per /gsd-plan-phase 20"
last_updated: "2026-06-19T18:05:00.000Z"
last_activity: 2026-06-19 -- Phase 19 complete — LeadsKanbanBoard + LeadsViewToggle (PIPE-01/PIPE-02)
stopped_at: "Phase 21+22 codice completo — pending: (1) migration 0010 a prod via SSH tunnel; (2) dati reali in profile.ts"
last_updated: "2026-06-20T14:00:00.000Z"
last_activity: 2026-06-20 -- Phase 21+22 eseguite insieme — agente AI Opus 4.8 + deck pubblico 20+ slide + entry point LeadDetail + sidebar globale
progress:
total_phases: 5
completed_phases: 2
total_plans: 4
completed_plans: 4
percent: 40
completed_phases: 4
total_plans: 7
completed_plans: 6
percent: 80
---
# Project State
@@ -26,10 +26,10 @@ See: .planning/PROJECT.md (updated 2026-06-13)
## Current Position
Phase: 20 (R3) Knowledge Base Cliente — pending planning
Plan:
Status: Ready to plan
Last activity: 2026-06-19 -- Phase 19 complete (19-01 done — PIPE-01/PIPE-02 verified)
Phase: 20 (R3) Knowledge Base Cliente — ready to execute
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
Status: Ready to execute
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
@@ -0,0 +1,72 @@
# Phase 21+22 — Agente AI Preventivo + Pagina Pubblica Deck
**Executed:** 2026-06-20
**Status:** Code complete — pending migration 0010 to prod
**Model used:** `claude-opus-4-8`
**Note:** Le fasi 21 (AI generation) e 22 (public page) sono state eseguite in un'unica sessione per coerenza architetturale.
---
## Scope eseguito
### Fase 21 — Agente AI (AI-01 / AI-02)
- `@anthropic-ai/sdk@0.105.0` installato
- `ANTHROPIC_API_KEY` aggiunta a `.env.local`
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (sezioni AI)
- `src/lib/proposal/agent.ts``generateProposalContent()` → Claude Opus 4.8, JSON strutturato validato Zod
- `src/lib/proposal/assemble.ts``assembleProposal()` fonde AI + offerta DB + config consulente
- `src/lib/proposal/profile.ts` — config statica consulente (bio, fatti, testimonianze, legal) — **editare con dati reali**
- `src/lib/proposal/queries.ts``listProposals`, `getProposalById`, `getProposalBySlug`
- `src/app/admin/preventivi/actions.ts``generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal`
- `src/app/admin/preventivi/page.tsx` — lista preventivi admin
- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — builder con pre-fill `?lead_id=X`
- `src/app/admin/preventivi/[id]/page.tsx` — review bozza + pubblica + elimina
- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
### Fase 22 — Pagina pubblica deck (PUB-01 / PUB-02)
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico, gestisce stati draft/published/accepted/rejected
- `src/app/preventivo/[slug]/actions.ts``acceptProposal` + `rejectProposal` con guard immutabilità `accepted_at`
- `src/components/public/proposal/ProposalDeck.tsx` — deck navigabile (frecce ←/→ + dot cliccabili + keyboard), light mode iamcavalli
- Sezioni (20+): Cover, Vision, Index, ChapterDivider, Strategist, Facts, Testimonials, ProblemNode, SynthesisDiagram, SolutionNode, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
### Schema DB
- `src/db/migrations/0010_proposals.sql``CREATE TABLE IF NOT EXISTS proposals (...)`**applicare a prod via SSH prima del push**
- `src/db/schema.ts` — tabella `proposals` + relations + `Proposal`/`NewProposal` types
---
## Flusso end-to-end
```
Lead (con transcript) → LeadDetail "Genera preventivo"
→ /admin/preventivi/genera?lead_id=X
→ seleziona offerta → "Genera con AI"
→ server action: legge transcript + offerta → chiama Claude Opus 4.8 → Zod validate
→ assembla AssembledProposal (AI + offerta + config) → salva in proposals (draft)
→ redirect /admin/preventivi/[id] → review bozza → "Pubblica"
→ pagina pubblica /preventivo/[slug] — deck 20+ slide
→ cliente sceglie tier A/B/C → accetta → accepted_at IMMUTABILE
```
---
## Pending post-push
1. **Migration 0010 a prod** via `ssh -L 54321:localhost:54321 root@178.104.27.55` + script node
2. **Dati reali in `src/lib/proposal/profile.ts`** — bio, credenziali, testimonianze, contatti, foto URL
3. **`ANTHROPIC_API_KEY` in Coolify** (già in `.env.local` per dev)
4. **Wave 5 (opzionale)** — badge CRM su accept/reject + email Resend
---
## Decisioni architetturali
| Decisione | Scelta | Motivo |
|-----------|--------|--------|
| Output AI | JSON strutturato → template fisso | Coerenza visiva garantita, zero rischio HTML rotto |
| Bio/testimonianze | File config `profile.ts` | Editing semplice, no UI v1 |
| Entry point | LeadDetail + sidebar globale | GSD prescrive LeadDetail; sidebar è additive |
| `content` | `jsonb` (non `text`) | Parser automatico postgres-js, type-safe |
| Modello | `claude-opus-4-8` | Migliore qualità copywriting strategico |
| Phase split | 21+22 eseguite insieme | Dipendenza diretta, nessun vantaggio a splitparle |
+63
View File
@@ -8,6 +8,7 @@
"name": "clienthub",
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -59,6 +60,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.105.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3802,6 +3824,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -6365,6 +6393,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7342,6 +7376,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -9038,6 +9085,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -9329,6 +9386,12 @@
"node": ">=8.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+163
View File
@@ -0,0 +1,163 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getProposalById } from "@/lib/proposal/queries";
import { publishProposal, deleteProposal } from "../actions";
import { ExternalLink, ArrowLeft, Trash2 } from "lucide-react";
export const revalidate = 0;
export default async function ProposalDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const proposal = await getProposalById(id);
if (!proposal) notFound();
const { content } = proposal;
const isDraft = proposal.state === "draft";
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Breadcrumb */}
<Link
href="/admin/preventivi"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Preventivi
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{proposal.title ?? "Senza titolo"}
</h1>
<div className="flex items-center gap-3 mt-2">
<StateBadge state={proposal.state} />
<span className="text-sm text-muted-foreground">
Generato il {proposal.createdAt.toLocaleDateString("it-IT")} · {proposal.model ?? "AI"}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Link pubblico */}
{(proposal.state === "published" || proposal.state === "accepted") && (
<a
href={`/preventivo/${proposal.slug}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors"
>
<ExternalLink size={14} />
Apri deck
</a>
)}
{/* Pubblica */}
{isDraft && (
<form action={publishProposal.bind(null, id)}>
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
Pubblica
</button>
</form>
)}
{/* Elimina (solo draft) */}
{isDraft && (
<form action={deleteProposal.bind(null, id)}>
<button
type="submit"
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title="Elimina bozza"
>
<Trash2 size={16} />
</button>
</form>
)}
</div>
</div>
{/* Link pubblico (draft) */}
{isDraft && (
<div className="text-xs text-muted-foreground bg-yellow-50 border border-yellow-200 rounded-md px-3 py-2">
Bozza non ancora visibile al cliente. Pubblica per generare il link pubblico.
</div>
)}
{!isDraft && (
<div className="text-xs bg-muted border border-border rounded-md px-3 py-2 font-mono">
{typeof window === "undefined" ? "" : window.location.origin}/preventivo/{proposal.slug}
</div>
)}
{/* Preview contenuto */}
<div className="space-y-4">
<h2 className="text-base font-semibold text-foreground">Anteprima contenuto</h2>
<Section title="Vision">
<p className="font-medium">{content.ai.vision.headline}</p>
<p className="text-muted-foreground text-sm mt-1">{content.ai.vision.body}</p>
</Section>
<Section title={`Problemi (${content.ai.problems.length})`}>
<div className="space-y-2">
{content.ai.problems.map((p) => (
<div key={p.id} className="border border-border rounded-md px-3 py-2">
<p className="font-medium text-sm">{p.id}. {p.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{p.subtitle}</p>
</div>
))}
</div>
</Section>
<Section title="Scope">
<p className="font-medium text-sm">{content.ai.scope.scopeTitle}</p>
<p className="text-sm text-muted-foreground mt-1">{content.ai.scope.scopeBody}</p>
</Section>
<Section title={`Tier offerta (${content.offer.tiers.length})`}>
<div className="grid grid-cols-3 gap-3">
{content.offer.tiers.map((t) => (
<div key={t.id} className="border border-border rounded-md p-3 text-sm">
<p className="font-semibold text-primary">{t.tierLetter}</p>
<p className="font-medium mt-0.5">{t.publicName}</p>
<p className="text-muted-foreground mt-1">
{t.publicPrice ? `${t.publicPrice}` : `${t.servicesTotal} (calcolato)`}
</p>
</div>
))}
</div>
</Section>
</div>
</div>
);
}
function StateBadge({ state }: { state: string }) {
const map: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
const info = map[state] ?? { label: state, className: "bg-gray-100 text-gray-700" };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${info.className}`}>
{info.label}
</span>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
{children}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use server";
import { db } from "@/db";
import { proposals, leads, clients } from "@/db/schema";
import { nanoid } from "nanoid";
import { eq, and, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { generateProposalContent } from "@/lib/proposal/agent";
import { assembleProposal } from "@/lib/proposal/assemble";
import { getOfferEditorData } from "@/lib/offer-queries";
import { getTranscripts } from "@/lib/lead-service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// Genera una bozza di preventivo con AI e salva nel DB
export async function generateProposalDraft(formData: FormData) {
await requireAdmin();
const leadId = formData.get("lead_id") as string | null;
const clientId = formData.get("client_id") as string | null;
const offerMacroId = formData.get("offer_macro_id") as string;
if (!offerMacroId) throw new Error("offer_macro_id richiesto");
if (!leadId && !clientId) throw new Error("lead_id o client_id richiesto");
// Carica dati necessari
const offer = await getOfferEditorData(offerMacroId);
if (!offer) throw new Error("Offerta non trovata");
let lead = undefined;
let client = undefined;
let transcripts: Awaited<ReturnType<typeof getTranscripts>> = [];
if (leadId) {
const [leadRow] = await db
.select({ id: leads.id, name: leads.name, email: leads.email, company: leads.company, notes: leads.notes })
.from(leads)
.where(eq(leads.id, leadId))
.limit(1);
lead = leadRow;
transcripts = await getTranscripts(leadId);
} else if (clientId) {
const [clientRow] = await db
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, brief: clients.brief })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
client = clientRow;
// Trascrizioni del cliente (via client_id)
const { clientTranscripts } = await import("@/db/schema");
const { eq: drizzleEq, desc } = await import("drizzle-orm");
transcripts = await db
.select()
.from(clientTranscripts)
.where(drizzleEq(clientTranscripts.client_id, clientId))
.orderBy(desc(clientTranscripts.call_date));
}
const MODEL = "claude-opus-4-8";
// Chiama agente AI
const { content: aiContent } = await generateProposalContent({
lead,
client,
transcripts,
offer,
});
// Assembla contenuto completo
const assembled = assembleProposal({
lead,
client,
offer,
aiContent,
model: MODEL,
});
// Salva nel DB
const id = nanoid();
const slug = nanoid();
const title = `${assembled.header.clientName} × ${assembled.header.offerTitle}`;
await db.insert(proposals).values({
id,
slug,
lead_id: leadId ?? null,
client_id: clientId ?? null,
offer_macro_id: offerMacroId,
title,
content: assembled as unknown as Record<string, unknown>,
model: MODEL,
state: "draft",
});
revalidatePath("/admin/preventivi");
redirect(`/admin/preventivi/${id}`);
}
// Pubblica la proposta (draft → published)
export async function publishProposal(id: string) {
await requireAdmin();
await db
.update(proposals)
.set({ state: "published", updated_at: new Date() })
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
revalidatePath(`/admin/preventivi/${id}`);
revalidatePath("/admin/preventivi");
}
// Aggiorna il titolo della proposta
export async function updateProposalTitle(id: string, title: string) {
await requireAdmin();
await db
.update(proposals)
.set({ title: title.trim(), updated_at: new Date() })
.where(eq(proposals.id, id));
revalidatePath(`/admin/preventivi/${id}`);
}
// Elimina una proposta (solo draft)
export async function deleteProposal(id: string) {
await requireAdmin();
await db
.delete(proposals)
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
revalidatePath("/admin/preventivi");
redirect("/admin/preventivi");
}
@@ -0,0 +1,159 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react";
import type { OfferListCard } from "@/lib/offer-queries";
type Props = {
leads: Array<{ id: string; name: string; company: string | null }>;
clients: Array<{ id: string; name: string; brand_name: string }>;
offers: OfferListCard[];
action: (formData: FormData) => Promise<void>;
preselectedLeadId?: string;
};
export function GeneraProposalForm({ leads, clients, offers, action, preselectedLeadId }: Props) {
const [subjectType, setSubjectType] = useState<"lead" | "client">(
preselectedLeadId ? "lead" : "lead"
);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await action(formData);
} catch (err) {
setError(err instanceof Error ? err.message : "Errore sconosciuto");
}
});
}
return (
<form onSubmit={handleSubmit} className="space-y-6 bg-white border border-border rounded-lg p-6">
{/* Tipo soggetto */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Soggetto</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setSubjectType("lead")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "lead"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Lead
</button>
<button
type="button"
onClick={() => setSubjectType("client")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "client"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Cliente
</button>
</div>
</div>
{/* Select lead o cliente */}
{subjectType === "lead" ? (
<div className="space-y-1">
<label htmlFor="lead_id" className="text-sm font-medium text-foreground">
Lead
</label>
<select
id="lead_id"
name="lead_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un lead</option>
{leads.map((l) => (
<option key={l.id} value={l.id} selected={l.id === preselectedLeadId}>
{l.name}{l.company ? `${l.company}` : ""}
</option>
))}
</select>
</div>
) : (
<div className="space-y-1">
<label htmlFor="client_id" className="text-sm font-medium text-foreground">
Cliente
</label>
<select
id="client_id"
name="client_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un cliente</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.brand_name} ({c.name})
</option>
))}
</select>
</div>
)}
{/* Select offerta */}
<div className="space-y-1">
<label htmlFor="offer_macro_id" className="text-sm font-medium text-foreground">
Offerta
</label>
<select
id="offer_macro_id"
name="offer_macro_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un&apos;offerta</option>
{offers.map((o) => (
<option key={o.id} value={o.id}>
{o.internal_name}{o.description ? `${o.description}` : ""}
</option>
))}
</select>
</div>
{/* Errore */}
{error && (
<p className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{error}
</p>
)}
{/* Info generazione */}
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
L&apos;AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
personalizzata. La generazione richiede 3060 secondi.
</div>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
>
{isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Generazione in corso
</>
) : (
"Genera con AI →"
)}
</button>
</form>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { getAllLeads } from "@/lib/lead-service";
import { getOfferListCards } from "@/lib/offer-queries";
import { db } from "@/db";
import { clients } from "@/db/schema";
import { asc } from "drizzle-orm";
import { generateProposalDraft } from "../actions";
import { GeneraProposalForm } from "./GeneraProposalForm";
export const revalidate = 0;
export default async function GeneraPage({
searchParams,
}: {
searchParams: Promise<{ lead_id?: string }>;
}) {
const { lead_id: preselectedLeadId } = await searchParams;
const [leads, offerCards, allClients] = await Promise.all([
getAllLeads(200),
getOfferListCards(),
db.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
.from(clients)
.orderBy(asc(clients.name)),
]);
const activeOffers = offerCards.filter((o) => !o.is_archived);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Genera preventivo</h1>
<p className="text-sm text-muted-foreground mt-1">
Scegli il lead o il cliente e l&apos;offerta. L&apos;AI legge i transcript e genera la bozza.
</p>
</div>
<GeneraProposalForm
leads={leads.map((l) => ({ id: l.id, name: l.name, company: l.company }))}
clients={allClients}
offers={activeOffers}
action={generateProposalDraft}
preselectedLeadId={preselectedLeadId}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { listProposals } from "@/lib/proposal/queries";
import { FileText, Plus } from "lucide-react";
export const revalidate = 0;
const STATE_LABELS: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
export default async function PreventiviPage() {
const proposals = await listProposals();
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1>
<p className="text-sm text-muted-foreground mt-1">
Preventivi generati con AI e pubblicati ai clienti
</p>
</div>
<Link
href="/admin/preventivi/genera"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Genera preventivo
</Link>
</div>
{/* Lista */}
{proposals.length === 0 ? (
<div className="text-center py-20 border border-dashed border-border rounded-lg">
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p>
<Link
href="/admin/preventivi/genera"
className="mt-4 inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Genera il primo preventivo
</Link>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Titolo</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Lead / Cliente</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Offerta</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Stato</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Data</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{proposals.map((p) => {
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
const subject = p.clientName ?? p.leadName ?? "—";
return (
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors">
<td className="px-4 py-3 font-medium">
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-primary transition-colors">
{p.title ?? "Senza titolo"}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{subject}</td>
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
{stateInfo.label}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.createdAt.toLocaleDateString("it-IT")}
</td>
<td className="px-4 py-3 text-right">
{p.state === "published" || p.state === "accepted" ? (
<a
href={`/preventivo/${p.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Apri
</a>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use server";
import { db } from "@/db";
import { proposals } from "@/db/schema";
import { eq, and, isNull, isNotNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
// Guard immutabilità — accepted_at è IMMUTABILE una volta valorizzato
async function getProposalForAction(slug: string) {
const [row] = await db
.select({ id: proposals.id, state: proposals.state, accepted_at: proposals.accepted_at })
.from(proposals)
.where(eq(proposals.slug, slug))
.limit(1);
return row ?? null;
}
export async function acceptProposal(
slug: string,
tier: string,
email?: string,
notes?: string
) {
const proposal = await getProposalForAction(slug);
if (!proposal) throw new Error("Proposta non trovata");
if (proposal.accepted_at) throw new Error("Proposta già accettata");
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
if (!["A", "B", "C"].includes(tier)) throw new Error("Tier non valido");
await db
.update(proposals)
.set({
state: "accepted",
selected_tier: tier,
accepted_at: new Date(),
client_email: email?.trim() || null,
client_notes: notes?.trim() || null,
updated_at: new Date(),
})
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
revalidatePath(`/preventivo/${slug}`);
}
export async function rejectProposal(slug: string, notes?: string) {
const proposal = await getProposalForAction(slug);
if (!proposal) throw new Error("Proposta non trovata");
if (proposal.accepted_at) throw new Error("Proposta già accettata");
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
await db
.update(proposals)
.set({
state: "rejected",
client_notes: notes?.trim() || null,
updated_at: new Date(),
})
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
revalidatePath(`/preventivo/${slug}`);
}
+39
View File
@@ -0,0 +1,39 @@
import { notFound } from "next/navigation";
import { getProposalBySlug } from "@/lib/proposal/queries";
import { ProposalDeck } from "@/components/public/proposal/ProposalDeck";
import { acceptProposal, rejectProposal } from "./actions";
export const revalidate = 0;
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const proposal = await getProposalBySlug(slug);
if (!proposal) return { title: "Preventivo non trovato" };
return {
title: proposal.title ?? "Documento di Soluzione · iamcavalli",
description: `Proposta riservata — ${proposal.content.header.clientName} × ${proposal.content.consultant.name}`,
};
}
export default async function ProposalPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const proposal = await getProposalBySlug(slug);
if (!proposal) notFound();
// Proposta non ancora pubblicata
if (proposal.state === "draft") {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<p className="text-muted-foreground text-sm">Questa proposta non è ancora disponibile.</p>
</div>
);
}
return (
<ProposalDeck
proposal={proposal}
onAccept={acceptProposal}
onReject={rejectProposal}
/>
);
}
+20 -7
View File
@@ -12,16 +12,18 @@ import {
Settings,
LogOut,
Zap,
FileText,
} from "lucide-react";
const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/offers", label: "Offerte", icon: Tag },
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
{ href: "/admin/offers", label: "Offerte", icon: Tag },
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
];
export function AdminSidebar() {
@@ -37,6 +39,17 @@ export function AdminSidebar() {
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
</div>
{/* CTA globale — genera preventivo */}
<div className="px-3 py-3 border-b border-white/10">
<Link
href="/admin/preventivi/genera"
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
>
<FileText size={13} />
Genera preventivo
</Link>
</div>
{/* Nav links */}
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
@@ -10,6 +10,7 @@ import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import { addLeadTag, removeLeadTag, renameLeadTag, deleteTranscript } from "@/app/admin/leads/actions";
import { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale";
import Link from "next/link";
import { LogActivityModal } from "./LogActivityModal";
import { SendQuoteModal } from "./SendQuoteModal";
import { EditLeadModal } from "./LeadForm";
@@ -99,6 +100,12 @@ export function LeadDetail({
<LogActivityModal leadId={lead.id} />
<TranscriptModal leadId={lead.id} />
<SendQuoteModal leadId={lead.id} />
<Link
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#DEF168] text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#d4e85e] transition-colors"
>
Genera preventivo
</Link>
<EditLeadModal lead={lead} />
</div>
</div>
@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { ProposalDetail } from "@/lib/proposal/queries";
import { CoverSection } from "./sections/CoverSection";
import { VisionSection } from "./sections/VisionSection";
import { IndexSection } from "./sections/IndexSection";
import { ChapterDivider } from "./sections/ChapterDivider";
import { StrategistSection } from "./sections/StrategistSection";
import { FactsSection } from "./sections/FactsSection";
import { TestimonialsSection } from "./sections/TestimonialsSection";
import { ProblemNodeSection } from "./sections/ProblemNodeSection";
import { SynthesisDiagramSection } from "./sections/SynthesisDiagramSection";
import { SolutionNodeSection } from "./sections/SolutionNodeSection";
import { SolutionSynthesisSection } from "./sections/SolutionSynthesisSection";
import { ScopeSection } from "./sections/ScopeSection";
import { DeliverablesSection } from "./sections/DeliverablesSection";
import { TimelineSection } from "./sections/TimelineSection";
import { PricingSection } from "./sections/PricingSection";
import { StagesRecapSection } from "./sections/StagesRecapSection";
import { ComparisonMatrixSection } from "./sections/ComparisonMatrixSection";
import { NextStepsSection } from "./sections/NextStepsSection";
import { ClosingSection } from "./sections/ClosingSection";
import { AcceptSection } from "./sections/AcceptSection";
type Props = {
proposal: ProposalDetail;
onAccept: (slug: string, tier: string, email?: string, notes?: string) => Promise<void>;
onReject: (slug: string, notes?: string) => Promise<void>;
};
export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
const { content, state, selectedTier, acceptedAt } = proposal;
const { ai, consultant, offer, header } = content;
const problems = ai.problems;
const solutions = ai.solutions;
// Costruisce la lista di slide dinamicamente
const testimonialPages = Math.ceil(consultant.testimonials.length / 9);
type Slide = { id: string; component: React.ReactNode };
const slides: Slide[] = [
// 01 — Cover
{ id: "cover", component: <CoverSection header={header} /> },
// 02 — Vision
{ id: "vision", component: <VisionSection vision={ai.vision} header={header} /> },
// 03 — Sommario
{ id: "index", component: <IndexSection /> },
// Cap.01 — Lo strategist
{ id: "ch01", component: <ChapterDivider number="01" title="Lo strategist." subtitle="Chi vi affianca in questo lavoro, con quali credenziali, con quale metodo e con quali precedenti dimostrabili." /> },
{ id: "strategist", component: <StrategistSection consultant={consultant} /> },
{ id: "facts", component: <FactsSection consultant={consultant} /> },
// Testimonianze (1N pagine da 9)
...Array.from({ length: testimonialPages }, (_, i) => ({
id: `testimonials-${i + 1}`,
component: (
<TestimonialsSection
testimonials={consultant.testimonials}
page={i + 1}
totalPages={testimonialPages}
/>
),
})),
// Cap.02 — Il problema
{ id: "ch02", component: <ChapterDivider number="02" title="Il problema." subtitle={`${problems.length} nodi rilevati nella discovery. Li chiamiamo per nome, uno per uno.`} /> },
// Nodi problema
...problems.map((p) => ({
id: `problem-${p.id}`,
component: <ProblemNodeSection problem={p} />,
})),
// Sintesi problema
{ id: "problem-synthesis", component: <SynthesisDiagramSection synthesis={ai.problemSynthesis} type="problem" /> },
// Cap.03 — La soluzione
{ id: "ch03", component: <ChapterDivider number="03" title="La soluzione." subtitle="Per ogni nodo, un'elevazione speculare. La struttura riprende l'ordine dei problemi e mostra dove la trasformazione vi porta." /> },
// Nodi soluzione
...solutions.map((s) => ({
id: `solution-${s.id}`,
component: <SolutionNodeSection solution={s} />,
})),
// Sintesi soluzione
{ id: "solution-synthesis", component: <SolutionSynthesisSection synthesis={ai.solutionSynthesis} /> },
// Cap.04 — L'esecuzione
{ id: "ch04", component: <ChapterDivider number="04" title="L'esecuzione." subtitle="Lo scope del lavoro, gli obiettivi, i deliverable, ciò che è incluso e ciò che non lo è. La timeline." /> },
{ id: "scope", component: <ScopeSection scope={ai.scope} /> },
{ id: "deliverables", component: <DeliverablesSection deliverables={ai.deliverables} /> },
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
// Cap.05 — Le opzioni
{ id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> },
{ id: "pricing", component: <PricingSection tiers={offer.tiers} /> },
// Stages recap + comparison
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
// Come procedere / Next steps
{ id: "nextsteps", component: <NextStepsSection consultant={consultant} header={header} offer={offer} /> },
// Accept/Reject (solo se published)
...(state === "published"
? [{
id: "accept",
component: (
<AcceptSection
slug={proposal.slug}
tiers={offer.tiers}
header={header}
onAccept={onAccept}
onReject={onReject}
/>
),
}]
: []),
// Closing
{ id: "closing", component: <ClosingSection consultant={consultant} header={header} state={state} selectedTier={selectedTier} acceptedAt={acceptedAt} /> },
];
const total = slides.length;
const [current, setCurrent] = useState(0);
const goTo = useCallback((i: number) => setCurrent(Math.max(0, Math.min(total - 1, i))), [total]);
const prev = useCallback(() => goTo(current - 1), [current, goTo]);
const next = useCallback(() => goTo(current + 1), [current, goTo]);
// Navigazione da tastiera
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") next();
if (e.key === "ArrowLeft" || e.key === "ArrowUp") prev();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [next, prev]);
return (
<div className="relative min-h-screen bg-white font-sans select-none">
{/* Slide corrente */}
<div className="min-h-screen flex flex-col">
{/* Topbar */}
<div className="fixed top-0 left-0 right-0 z-20 flex items-center justify-between px-8 py-4 bg-white/90 backdrop-blur-sm border-b border-border">
<span className="text-xs font-mono text-muted-foreground tracking-widest uppercase">
Documento di Soluzione · {header.clientName} · {header.date}
</span>
<span className="text-xs font-mono text-muted-foreground">
{String(current + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
</span>
</div>
{/* Contenuto slide */}
<div className="flex-1 pt-16 pb-20">
{slides[current].component}
</div>
{/* Bottom nav */}
<div className="fixed bottom-0 left-0 right-0 z-20 flex items-center justify-between px-8 py-4 bg-white/90 backdrop-blur-sm border-t border-border">
{/* Frecce */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<button
onClick={prev}
disabled={current === 0}
className="disabled:opacity-30 hover:text-foreground transition-colors"
aria-label="Precedente"
>
</button>
<button
onClick={next}
disabled={current === total - 1}
className="disabled:opacity-30 hover:text-foreground transition-colors"
aria-label="Prossimo"
>
</button>
<span className="ml-2 hidden sm:inline">CLICK DOTS</span>
</div>
{/* Dot navigator */}
<div className="flex items-center gap-1 overflow-x-auto max-w-[60vw] py-1">
{slides.map((s, i) => (
<button
key={s.id}
onClick={() => goTo(i)}
aria-label={`Vai alla slide ${i + 1}`}
className={`shrink-0 rounded-full transition-all ${
i === current
? "w-6 h-2 bg-primary"
: "w-2 h-2 bg-border hover:bg-muted-foreground"
}`}
/>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react";
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = {
slug: string;
tiers: AssembledProposal["offer"]["tiers"];
header: AssembledProposal["header"];
onAccept: (slug: string, tier: string, email?: string, notes?: string) => Promise<void>;
onReject: (slug: string, notes?: string) => Promise<void>;
};
export function AcceptSection({ slug, tiers, header, onAccept, onReject }: Props) {
const [selectedTier, setSelectedTier] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [notes, setNotes] = useState("");
const [action, setAction] = useState<"accept" | "reject" | null>(null);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleAccept() {
if (!selectedTier) { setError("Seleziona un tier per procedere"); return; }
setError(null);
startTransition(async () => {
try {
await onAccept(slug, selectedTier, email || undefined, notes || undefined);
} catch (e) {
setError(e instanceof Error ? e.message : "Errore");
}
});
}
function handleReject() {
setError(null);
startTransition(async () => {
try {
await onReject(slug, notes || undefined);
} catch (e) {
setError(e instanceof Error ? e.message : "Errore");
}
});
}
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Accettazione formale
</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Scegli il tuo <span className="text-primary">tier.</span>
</h2>
<p className="text-base text-muted-foreground mt-4">
Seleziona l&apos;opzione che preferisci. La conferma via questo modulo attiva il lavoro.
</p>
</div>
{/* Selezione tier */}
<div className="grid grid-cols-3 gap-4">
{tiers.map((tier) => (
<button
key={tier.tierLetter}
onClick={() => setSelectedTier(tier.tierLetter)}
className={`rounded-xl border-2 p-6 text-left transition-all ${
selectedTier === tier.tierLetter
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40"
}`}
>
<p className="text-2xl font-light text-primary">{tier.tierLetter}</p>
<p className="font-medium text-foreground mt-1">{tier.publicName}</p>
<p className="text-sm text-muted-foreground mt-2">
{tier.publicPrice ? `${parseFloat(tier.publicPrice).toLocaleString("it-IT")}` : `${parseFloat(tier.servicesTotal).toLocaleString("it-IT")}`}
</p>
</button>
))}
</div>
{/* Email e note */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Email (opzionale)
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tua@email.com"
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Note (opzionale)
</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Note per il consulente…"
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{/* CTA */}
<div className="flex items-center gap-4">
<button
onClick={handleAccept}
disabled={isPending}
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
>
{isPending && action === "accept" ? <Loader2 size={16} className="animate-spin" /> : null}
Accetto {selectedTier ? `Opzione ${selectedTier}` : "scegli tier"}
</button>
<button
onClick={() => { setAction("reject"); handleReject(); }}
disabled={isPending}
className="px-6 py-3 border border-border rounded-xl text-muted-foreground hover:text-destructive hover:border-destructive/40 disabled:opacity-60 transition-colors text-sm"
>
Non procedo
</button>
</div>
<p className="text-xs font-mono text-muted-foreground">
{header.consultantName} · Accettazione formale valida {header.validityDays} giorni dalla data di invio.
</p>
</div>
);
}
@@ -0,0 +1,15 @@
type Props = { number: string; title: string; subtitle: string };
export function ChapterDivider({ number, title, subtitle }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-6">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Capitolo</p>
<div className="space-y-2">
<p className="text-8xl font-light text-primary">{number}</p>
<div className="w-12 h-px bg-primary" />
</div>
<h2 className="text-5xl font-light text-foreground">{title}</h2>
<p className="text-lg text-muted-foreground max-w-lg">{subtitle}</p>
</div>
);
}
@@ -0,0 +1,69 @@
import type { ConsultantProfile } from "@/lib/proposal/profile";
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = {
consultant: ConsultantProfile;
header: AssembledProposal["header"];
state: string;
selectedTier: string | null;
acceptedAt: Date | null;
};
export function ClosingSection({ consultant, header, state, selectedTier, acceptedAt }: Props) {
return (
<div className="min-h-screen flex flex-col justify-between px-16 py-24">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Come si parte</p>
<div className="space-y-6">
{state === "accepted" && acceptedAt && selectedTier ? (
<div className="space-y-4">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-full text-sm font-medium">
Proposta accettata Opzione {selectedTier}
</div>
<h2 className="text-5xl font-light text-foreground">
Il kickoff è già <span className="text-primary">in calendario.</span>
</h2>
<p className="text-lg text-muted-foreground max-w-xl">
Hai scelto l&apos;Opzione {selectedTier} il{" "}
{acceptedAt.toLocaleDateString("it-IT", { day: "2-digit", month: "long", year: "numeric" })}.
Il consulente ti contatterà a breve per definire la data di kickoff.
</p>
</div>
) : state === "rejected" ? (
<div className="space-y-4">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-100 text-amber-800 rounded-full text-sm font-medium">
Proposta non accettata
</div>
<h2 className="text-5xl font-light text-foreground">Grazie per il tuo tempo.</h2>
<p className="text-lg text-muted-foreground">
Se cambierai idea o vorrai discutere di altri progetti, siamo qui.
</p>
</div>
) : (
<div className="space-y-4">
<h2 className="text-5xl font-light text-foreground">
Il kickoff è già <span className="text-primary">in calendario.</span>
</h2>
<p className="text-lg text-muted-foreground max-w-xl">
Una volta confermata l&apos;opzione, la prima sessione viene pianificata entro la settimana successiva.
Tutto parte da .
</p>
</div>
)}
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{consultant.name} · {consultant.contact.email}
{consultant.contact.phone ? ` · ${consultant.contact.phone}` : ""}
</p>
{consultant.contact.website && (
<p className="text-xs font-mono text-muted-foreground">{consultant.contact.website}</p>
)}
<p className="text-xs font-mono text-muted-foreground mt-4">
Documento di Soluzione · {header.clientName} · {header.date}
</p>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import type { ComparisonRow } from "@/lib/proposal/schema";
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = {
matrix: ComparisonRow[];
tiers: AssembledProposal["offer"]["tiers"];
};
export function ComparisonMatrixSection({ matrix, tiers }: Props) {
const tierLetters = tiers.map((t) => t.tierLetter);
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-8">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Riepilogo del lavoro</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Le voci che <span className="text-primary">compongono</span> ogni opzione.
</h2>
</div>
<div className="border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted border-b border-border">
<tr>
<th className="px-6 py-4 text-left font-mono text-xs tracking-widest text-muted-foreground uppercase">
Componente del lavoro
</th>
{tierLetters.map((l) => (
<th key={l} className="px-6 py-4 text-center font-mono text-xs tracking-widest text-muted-foreground uppercase w-24">
{l}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{matrix.map((row, i) => {
const values = [row.inA, row.inB, row.inC];
return (
<tr key={i} className="hover:bg-muted/30 transition-colors">
<td className="px-6 py-4">
<p className="font-medium text-foreground">{row.component}</p>
<p className="text-xs text-muted-foreground mt-0.5">{row.description}</p>
</td>
{values.slice(0, tierLetters.length).map((included, j) => (
<td key={j} className="px-6 py-4 text-center">
{included ? (
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
) : (
<span className="inline-block w-3 h-3 rounded-full border-2 border-border" />
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs font-mono text-muted-foreground">
Le tre opzioni si compongono progressivamente. La C eredita tutto da B, che a sua volta eredita tutto da A.
</p>
</div>
);
}
@@ -0,0 +1,39 @@
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = { header: AssembledProposal["header"] };
export function CoverSection({ header }: Props) {
return (
<div className="min-h-screen flex flex-col justify-between px-16 py-24">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Documento di Soluzione
</p>
<div className="space-y-6">
<h1 className="text-6xl font-light leading-tight tracking-tight text-foreground">
{header.offerTitle.split(" ").map((word, i, arr) => {
const isLast = i === arr.length - 1;
return (
<span key={i}>
{isLast ? <span className="text-primary font-normal">{word}</span> : `${word} `}
</span>
);
})}
</h1>
{header.offerDescription && (
<p className="text-lg text-muted-foreground max-w-2xl">{header.offerDescription}</p>
)}
<p className="text-base text-foreground">
{header.clientName} × {header.consultantName}
</p>
<div className="flex items-center gap-6 text-xs font-mono text-muted-foreground">
<span>Data: {header.date}</span>
<span>·</span>
<span>Validità {header.validityDays} giorni</span>
</div>
</div>
<div />
</div>
);
}
@@ -0,0 +1,51 @@
import type { ProposalContent } from "@/lib/proposal/schema";
import { X } from "lucide-react";
type Props = { deliverables: ProposalContent["deliverables"] };
export function DeliverablesSection({ deliverables }: Props) {
return (
<div className="min-h-screen flex items-center px-16 py-24">
<div className="grid grid-cols-2 gap-8 w-full">
{/* Deliverable */}
<div className="border border-primary/20 bg-primary/5 rounded-xl p-8 space-y-4">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<div className="w-5 h-5 rounded-full bg-primary" />
</div>
<p className="text-xs font-mono tracking-widest text-primary uppercase">Deliverable · Output finali</p>
<h3 className="text-lg font-medium text-foreground">Quattro asset progressivi, uno per opzione.</h3>
<ul className="space-y-2">
{deliverables.deliverables.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
<span className="text-primary mt-0.5"></span>
<span dangerouslySetInnerHTML={{ __html: d }} />
</li>
))}
</ul>
</div>
{/* Out of scope */}
<div className="border border-border rounded-xl p-8 space-y-4">
<div className="w-10 h-10 rounded-full border-2 border-border flex items-center justify-center text-muted-foreground">
</div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Confini · Ciò che non è incluso</p>
<div className="border border-border rounded-lg p-4 bg-muted/30 space-y-1">
<p className="text-sm font-medium text-foreground">
Ciò che non è esplicitamente indicato è da considerarsi escluso.{" "}
<span className="italic">A titolo di esempio:</span>
</p>
</div>
<ul className="space-y-2">
{deliverables.outOfScope.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
<X size={14} className="mt-0.5 shrink-0" />
<span>{d}</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { ConsultantProfile } from "@/lib/proposal/profile";
type Props = { consultant: ConsultantProfile };
export function FactsSection({ consultant }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-12">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Tre fatti</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Cosa porto al <span className="text-primary">tavolo.</span>
</h2>
</div>
<div className="grid grid-cols-3 gap-6">
{consultant.facts.map((fact, i) => (
<div key={i} className="border border-border rounded-xl p-8 space-y-4">
<p className="text-5xl font-light text-primary">{fact.value}</p>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">{fact.label}</p>
<p className="text-sm text-foreground font-medium">{fact.description}</p>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,30 @@
const CHAPTERS = [
{ n: "01", title: "Lo strategist", subtitle: "Chi vi affianca, con quale metodo e con quali precedenti." },
{ n: "02", title: "Il problema", subtitle: "I nodi rilevati nella discovery, ognuno chiamato per nome." },
{ n: "03", title: "La soluzione", subtitle: "L'elevazione speculare a ciascun nodo — con il risultato concreto." },
{ n: "04", title: "L'esecuzione", subtitle: "Scope, deliverable, ciò che non è incluso, timeline." },
{ n: "05", title: "Le opzioni", subtitle: "Tre intensità progressive con valore di mercato e pricing." },
];
export function IndexSection() {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-8">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sommario</p>
<h2 className="text-5xl font-light text-foreground">
Cinque <span className="text-primary">capitoli.</span>
</h2>
<div className="divide-y divide-border">
{CHAPTERS.map((c) => (
<div key={c.n} className="flex items-baseline justify-between py-5 gap-8">
<div className="flex items-baseline gap-6">
<span className="text-2xl font-mono text-primary">{c.n}</span>
<span className="text-xl text-foreground">{c.title}</span>
</div>
<span className="text-sm text-muted-foreground text-right max-w-xs">{c.subtitle}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import type { ConsultantProfile } from "@/lib/proposal/profile";
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = {
consultant: ConsultantProfile;
header: AssembledProposal["header"];
offer: AssembledProposal["offer"];
};
export function NextStepsSection({ consultant, header }: Props) {
// Calcola la data di scadenza (validityDays dalla data di generazione)
const expiryNote = `Valida ${header.validityDays} giorni dalla data di invio.`;
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Come procedere ora</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Tre passaggi <br />
<span className="text-primary">per attivare il lavoro.</span>
</h2>
</div>
<div className="space-y-4">
{consultant.nextSteps.map((step) => (
<div key={step.number} className="flex items-start gap-6">
<span className="text-xl font-mono text-primary shrink-0">{step.number}</span>
<p className="text-base text-foreground leading-relaxed">
{step.highlightedPart ? (
<>
{step.text.split(step.highlightedPart)[0]}
<strong>{step.highlightedPart}</strong>
{step.text.split(step.highlightedPart)[1]}
</>
) : (
step.text
)}
</p>
</div>
))}
</div>
<div className="border-t border-border pt-6 space-y-1">
<p className="text-sm font-medium text-foreground">
{consultant.name} · {consultant.contact.email}
{consultant.contact.phone ? ` · ${consultant.contact.phone}` : ""}
</p>
<p className="text-xs font-mono text-muted-foreground">{expiryNote}</p>
</div>
{/* Legale + accettazione */}
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="font-mono uppercase tracking-widest text-foreground text-[10px]">Termini legali</p>
<p>{consultant.legal.revisions}</p>
<p>{consultant.legal.ip}</p>
<p>{consultant.legal.nda}</p>
<p>Foro competente: {consultant.legal.forum}</p>
</div>
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="font-mono uppercase tracking-widest text-foreground text-[10px]">Accettazione</p>
<p>{consultant.legal.acceptanceInstructions}</p>
<p className="mt-2 font-medium text-foreground">{expiryNote}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = { tiers: AssembledProposal["offer"]["tiers"] };
function formatEur(val: string | null | undefined): string {
if (!val) return "—";
const n = parseFloat(val);
return `${n.toLocaleString("it-IT")}`;
}
export function PricingSection({ tiers }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Le tre opzioni</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Tre tappe, <span className="text-primary">un solo risultato.</span>
</h2>
</div>
<div className="grid grid-cols-3 gap-6">
{tiers.map((tier, i) => {
const isHighlighted = i === 1; // tier B highlighted di default
const price = tier.publicPrice ?? tier.servicesTotal;
const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`;
return (
<div
key={tier.id}
className={`rounded-xl p-8 space-y-6 border ${
isHighlighted
? "border-primary bg-primary/5"
: "border-border bg-white"
}`}
>
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Opzione {tier.tierLetter} / {durationLabel}
</p>
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
</div>
<span className="text-xs font-mono bg-primary/10 text-primary px-2 py-1 rounded">
{tier.tierLetter}
</span>
</div>
<div className="space-y-1">
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
<p className="text-xs text-muted-foreground">
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
</p>
</div>
{/* Servizi inclusi */}
{tier.services.length > 0 && (
<ul className="space-y-1.5">
{tier.services.map((s) => (
<li key={s.id} className="flex items-start gap-2 text-sm text-foreground">
<span className="text-primary mt-0.5"></span>
<span>{s.name}</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
<p className="text-xs font-mono text-muted-foreground">
Le tre opzioni si compongono progressivamente. La C eredita tutto da B, che a sua volta eredita tutto da A.
</p>
</div>
);
}
@@ -0,0 +1,34 @@
import type { ProblemNode } from "@/lib/proposal/schema";
type Props = { problem: ProblemNode };
export function ProblemNodeSection({ problem }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-8 max-w-4xl">
<div>
<p className="text-xs font-mono tracking-widest text-destructive uppercase">
Problema {problem.id} · {problem.subtitle}
</p>
<h2 className="text-5xl font-light text-foreground mt-4 leading-tight">
{problem.title.split(problem.title.split(" ").slice(-2).join(" "))[0]}
<span className="text-destructive">{problem.title.split(" ").slice(-2).join(" ")}</span>
</h2>
<p className="text-base text-muted-foreground mt-4 leading-relaxed max-w-2xl">
{problem.body}
</p>
</div>
<div className="border border-destructive/30 bg-destructive/5 rounded-xl p-6 space-y-2">
<p className="text-xs font-mono tracking-widest text-destructive uppercase">Il rischio</p>
<p className="text-sm text-foreground leading-relaxed">{problem.risk}</p>
</div>
<div className="border-l-2 border-border pl-6 space-y-1">
<p className="text-sm text-muted-foreground italic leading-relaxed">
&ldquo;{problem.quote}&rdquo;
</p>
<p className="text-xs font-mono text-muted-foreground">{problem.quoteAttribution}</p>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import type { ProposalContent } from "@/lib/proposal/schema";
import { CheckCircle2 } from "lucide-react";
type Props = { scope: ProposalContent["scope"] };
export function ScopeSection({ scope }: Props) {
return (
<div className="min-h-screen flex items-center px-16 py-24">
<div className="grid grid-cols-2 gap-12 w-full">
{/* Scope */}
<div className="border border-primary/20 bg-primary/5 rounded-xl p-8 space-y-4">
<div className="w-10 h-10 rounded-full border border-primary flex items-center justify-center">
<div className="w-5 h-5 rounded-full bg-primary/30" />
</div>
<p className="text-xs font-mono tracking-widest text-primary uppercase">Scope · Capitolo del lavoro</p>
<h3 className="text-xl font-medium text-foreground">{scope.scopeTitle}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{scope.scopeBody}</p>
</div>
{/* Obiettivi */}
<div className="border border-border bg-muted/30 rounded-xl p-8 space-y-4">
<div className="w-10 h-10 rounded-full border border-border flex items-center justify-center">
<div className="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[10px] border-b-foreground" />
</div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Obiettivi · Punti d&apos;arrivo</p>
<h3 className="text-xl font-medium text-foreground">Quattro risultati concreti, misurabili al termine.</h3>
<ul className="space-y-2">
{scope.objectives.map((obj, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
<span dangerouslySetInnerHTML={{ __html: obj }} />
</li>
))}
</ul>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import type { SolutionNode } from "@/lib/proposal/schema";
type Props = { solution: SolutionNode };
export function SolutionNodeSection({ solution }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-8 max-w-4xl">
<div>
<p className="text-xs font-mono tracking-widest text-primary uppercase">
{solution.subtitle}
</p>
<h2 className="text-5xl font-light text-foreground mt-4 leading-tight">
{solution.title}
</h2>
</div>
<div className="border border-primary/20 bg-primary/5 rounded-xl p-6 space-y-2">
<p className="text-xs font-mono tracking-widest text-primary uppercase">La trasformazione</p>
<p className="text-sm text-foreground leading-relaxed">{solution.transformation}</p>
</div>
<div className="border border-border rounded-xl p-6 space-y-3">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Attraverso cosa</p>
<ul className="space-y-2">
{solution.throughWhat.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
<span className="text-primary mt-0.5"></span>
<span dangerouslySetInnerHTML={{ __html: item }} />
</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import type { ProposalContent } from "@/lib/proposal/schema";
type Props = { synthesis: ProposalContent["solutionSynthesis"] };
export function SolutionSynthesisSection({ synthesis }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sintesi della soluzione</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Una direzione, <span className="text-success">cinque elevazioni.</span>
</h2>
<p className="text-base text-muted-foreground mt-4 max-w-2xl">
Da un&apos;unica decisione strategica a monte si declinano cinque movimenti specifici. Il sistema operativo che resta al team interno.
</p>
</div>
<div className="space-y-6">
{/* Direzione */}
<div className="border border-primary/20 bg-primary/5 rounded-xl p-6 text-center mx-auto max-w-lg">
<p className="text-xs font-mono tracking-widest text-primary uppercase mb-2">La direzione</p>
<p className="text-sm font-medium text-foreground">{synthesis.direction}</p>
</div>
{/* Connettori */}
<div className="flex justify-center">
<div className="w-px h-8 bg-primary/30" />
</div>
{/* Elevazioni */}
<div className="flex items-center justify-between gap-4">
{synthesis.elevations.map((e, i) => (
<div key={e.id} className="flex-1 border border-border rounded-lg px-4 py-3 text-center">
<p className="text-xs font-mono text-muted-foreground mb-1">0{i + 1} </p>
<p className="text-sm font-medium text-foreground">{e.label}</p>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import type { ProposalContent } from "@/lib/proposal/schema";
type Props = { stages: ProposalContent["stagesRecap"]["stages"] };
export function StagesRecapSection({ stages }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Recap del progresso</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Tre tappe verso <span className="text-primary">la trasformazione.</span>
</h2>
<p className="text-base text-muted-foreground mt-4 max-w-3xl">
Le tre opzioni non sono pacchetti scollegati: sono{" "}
<strong>tre tappe progressive</strong> dello stesso percorso. Ognuna costruisce sopra la precedente.
Insieme portano alla <strong>trasformazione totale</strong>.
</p>
</div>
<div className="flex items-start gap-4">
{stages.map((stage, i) => (
<div key={i} className="flex-1">
<div className="border border-primary/20 rounded-xl p-6 space-y-3">
<p className="text-4xl font-light text-primary">{stage.number}</p>
<p className="text-xs font-mono tracking-widest text-primary uppercase">{stage.label}</p>
<h3 className="text-xl font-medium text-foreground">{stage.headline}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{stage.body}</p>
<div className="border border-border rounded-md px-3 py-2 bg-muted/30">
<p className="text-xs font-mono text-muted-foreground">
{stage.deliverable}
</p>
</div>
</div>
{i < stages.length - 1 && (
<div className="hidden" /* arrow between stages — gestito dal grid */ />
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import type { ConsultantProfile } from "@/lib/proposal/profile";
import { CheckCircle2 } from "lucide-react";
type Props = { consultant: ConsultantProfile };
export function StrategistSection({ consultant }: Props) {
return (
<div className="min-h-screen flex items-center px-16 py-24">
<div className="grid grid-cols-3 gap-12 w-full">
{/* Foto */}
<div className="flex flex-col items-center justify-center">
<div className="w-48 h-64 rounded-xl border-2 border-primary bg-muted flex items-center justify-center overflow-hidden">
{consultant.photoUrl ? (
<img src={consultant.photoUrl} alt={consultant.name} className="w-full h-full object-cover" />
) : (
<span className="text-muted-foreground text-sm">{consultant.name[0]}</span>
)}
</div>
</div>
{/* Bio */}
<div className="col-span-2 space-y-6">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Strategist · Brand &amp; Business
</p>
<h2 className="text-4xl font-light text-foreground mt-2">{consultant.name}</h2>
</div>
<p className="text-base font-medium text-foreground leading-relaxed">{consultant.bio}</p>
<ul className="space-y-2">
{consultant.credentials.map((c, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
<span dangerouslySetInnerHTML={{ __html: c }} />
</li>
))}
</ul>
<div className="p-4 bg-muted rounded-lg text-sm text-foreground leading-relaxed border-l-2 border-primary">
{consultant.contact.website && (
<span className="font-mono text-xs text-muted-foreground">{consultant.contact.website}</span>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import type { ProposalContent } from "@/lib/proposal/schema";
type Props = {
synthesis: ProposalContent["problemSynthesis"];
type: "problem";
};
export function SynthesisDiagramSection({ synthesis }: Props) {
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sintesi del problema</p>
<h2 className="text-5xl font-light text-foreground mt-4">
Cinque nodi, <span className="text-destructive">una sola radice.</span>
</h2>
<p className="text-base text-muted-foreground mt-4 max-w-2xl">
I cinque nodi convergono su un&apos;unica domanda strategica non ancora risolta. Sciolto quel nodo, gli altri trovano il proprio posto.
</p>
</div>
{/* Diagramma semplice: nodi → radice comune */}
<div className="space-y-8">
{/* Nodi */}
<div className="flex items-center justify-between gap-4">
{synthesis.nodeLabels.map((label, i) => (
<div
key={i}
className="flex-1 border border-border rounded-lg px-4 py-3 text-center"
>
<p className="text-xs font-mono text-muted-foreground mb-1">0{i + 1} </p>
<p className="text-sm font-medium text-foreground">{label}</p>
</div>
))}
</div>
{/* Connettori */}
<div className="flex justify-center">
<div className="w-px h-8 bg-destructive/30" />
</div>
{/* Radice */}
<div className="mx-auto max-w-lg border border-destructive/30 bg-destructive/5 rounded-xl p-6 text-center">
<p className="text-xs font-mono tracking-widest text-destructive uppercase mb-2">
La radice comune
</p>
<p className="text-sm font-medium text-foreground">{synthesis.rootCause}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import type { Testimonial } from "@/lib/proposal/profile";
type Props = {
testimonials: Testimonial[];
page: number;
totalPages: number;
};
export function TestimonialsSection({ testimonials, page, totalPages }: Props) {
const perPage = 9;
const slice = testimonials.slice((page - 1) * perPage, page * perPage);
return (
<div className="min-h-screen flex flex-col px-16 py-24 space-y-8">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Pagina {page} di {totalPages} · Cosa dicono di me
</p>
<h2 className="text-4xl font-light text-foreground mt-4">
Una selezione dalle <span className="text-primary">testimonianze</span> di chi vi ha preceduto.
</h2>
</div>
<div className="grid grid-cols-3 gap-4 flex-1">
{slice.map((t, i) => (
<div key={i} className="border border-border rounded-lg p-5 space-y-3 flex flex-col">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary shrink-0">
{t.name[0]}
</div>
<div>
<p className="text-sm font-medium text-foreground leading-tight">{t.name}</p>
<p className="text-xs font-mono text-primary">{t.role}</p>
</div>
</div>
<p className="text-sm text-muted-foreground italic leading-relaxed flex-1">
&ldquo;{t.quote}&rdquo;
</p>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import type { ProposalContent } from "@/lib/proposal/schema";
type Props = { timeline: ProposalContent["timeline"] };
const COLOR_MAP = {
cyan: { bg: "bg-cyan-400", text: "text-cyan-900" },
purple: { bg: "bg-violet-400", text: "text-violet-900" },
green: { bg: "bg-emerald-400",text: "text-emerald-900" },
};
export function TimelineSection({ timeline }: Props) {
const weeks = Array.from({ length: timeline.totalWeeks }, (_, i) => i + 1);
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-10">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Timeline</p>
<h2 className="text-5xl font-light text-foreground mt-4">
{timeline.totalWeeks} settimane di <span className="text-primary">progressione.</span>
</h2>
</div>
<div className="space-y-4">
{/* Header settimane */}
<div className="grid gap-2" style={{ gridTemplateColumns: `140px repeat(${timeline.totalWeeks}, 1fr)` }}>
<div />
{weeks.map((w) => (
<div key={w} className="text-center text-xs font-mono text-muted-foreground">W{w}</div>
))}
</div>
{/* Righe fase */}
{timeline.phases.map((phase, i) => {
const colors = COLOR_MAP[phase.color];
return (
<div
key={i}
className="grid gap-2 items-center"
style={{ gridTemplateColumns: `140px repeat(${timeline.totalWeeks}, 1fr)` }}
>
<div className="text-xs text-muted-foreground font-mono truncate pr-2">
{i === 0 ? "OPZIONE A" : i === 1 ? "OPZIONE B" : "OPZIONE C"}
<br />
<span className="text-[10px] text-muted-foreground/60">
{phase.endWeek - phase.startWeek + 1} settimane
</span>
</div>
{weeks.map((w) => {
const active = w >= phase.startWeek && w <= phase.endWeek;
const isFirst = w === phase.startWeek;
const isLast = w === phase.endWeek;
return (
<div
key={w}
className={`h-10 ${active ? `${colors.bg} ${isFirst ? "rounded-l-md" : ""} ${isLast ? "rounded-r-md" : ""}` : ""} flex items-center ${isFirst ? "justify-start pl-3" : ""}`}
>
{isFirst && (
<span className={`text-xs font-medium ${colors.text} whitespace-nowrap`}>
{phase.label}
</span>
)}
</div>
);
})}
</div>
);
})}
</div>
{/* Legenda */}
<div className="flex items-center gap-6">
{timeline.phases.map((phase, i) => {
const colors = COLOR_MAP[phase.color];
return (
<div key={i} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-sm ${colors.bg}`} />
<span className="text-xs text-muted-foreground">{phase.label}</span>
</div>
);
})}
</div>
<p className="text-xs font-mono text-muted-foreground">
{timeline.disclaimer}
</p>
</div>
);
}
@@ -0,0 +1,33 @@
import type { AssembledProposal } from "@/lib/proposal/assemble";
import type { ProposalContent } from "@/lib/proposal/schema";
type Props = {
vision: ProposalContent["vision"];
header: AssembledProposal["header"];
};
export function VisionSection({ vision, header }: Props) {
const words = vision.headline.split(" ");
const mid = Math.floor(words.length / 2);
return (
<div className="min-h-screen flex flex-col justify-center px-16 py-24 space-y-8">
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Dove andiamo
</p>
<h2 className="text-5xl font-light leading-tight tracking-tight text-foreground">
{words.slice(0, mid).join(" ")}{" "}
<span className="text-primary">{words.slice(mid).join(" ")}</span>
</h2>
<p className="text-lg text-muted-foreground max-w-2xl leading-relaxed">
{vision.body}
</p>
<p className="text-sm text-muted-foreground">
{header.clientName} × {header.consultantName}
</p>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
-- Phase 21/22: Proposals — preventivo AI generato + pagina pubblica navigabile.
-- Additive only. No drops/truncates (Data Safety LOCKED).
-- Applicare a prod via SSH tunnel PRIMA di pushare il codice dipendente.
--
-- content (jsonb): l'intero ProposalContent assemblato (sezioni AI + offerta + config).
-- slug: identificatore pubblico URL-safe per /preventivo/[slug].
-- selected_tier: 'A' | 'B' | 'C' scelto dal cliente in fase di accettazione.
-- accepted_at: IMMUTABILE una volta valorizzato (audit, pattern quotes/deliverables).
CREATE TABLE IF NOT EXISTS proposals (
id text PRIMARY KEY NOT NULL,
slug text UNIQUE NOT NULL,
lead_id text REFERENCES leads(id) ON DELETE SET NULL,
client_id text REFERENCES clients(id) ON DELETE SET NULL,
offer_macro_id text NOT NULL REFERENCES offer_macros(id) ON DELETE RESTRICT,
title text,
content jsonb NOT NULL,
model text,
state text NOT NULL DEFAULT 'draft', -- draft | published | accepted | rejected
selected_tier text CHECK (selected_tier IN ('A', 'B', 'C')),
accepted_at timestamp with time zone,
client_email text,
client_notes text,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS proposals_slug_idx ON proposals (slug);
CREATE INDEX IF NOT EXISTS proposals_client_id_idx ON proposals (client_id);
CREATE INDEX IF NOT EXISTS proposals_lead_id_idx ON proposals (lead_id);
CREATE INDEX IF NOT EXISTS proposals_offer_macro_id_idx ON proposals (offer_macro_id);
+49 -1
View File
@@ -5,6 +5,7 @@ import {
numeric,
timestamp,
boolean,
jsonb,
primaryKey,
uniqueIndex,
index,
@@ -518,11 +519,46 @@ export const clientTranscripts = pgTable("client_transcripts", {
.defaultNow(),
});
// ============ PROPOSALS TABLE (Preventivo AI — Phase 21/22) ============
// Preventivo generato dall'agente AI e pubblicato come pagina pubblica
// navigabile su /preventivo/[slug]. content (jsonb) contiene l'intero
// ProposalContent assemblato (sezioni AI + offerta + config consulente).
// lead_id/client_id nullable (ON DELETE SET NULL — non perdere la proposta se
// il lead viene rimosso). offer_macro_id RESTRICT — la proposta riferisce
// l'offerta sorgente. accepted_at IMMUTABILE una volta valorizzato.
export const proposals = pgTable("proposals", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
slug: text("slug")
.notNull()
.unique()
.$defaultFn(() => nanoid()),
lead_id: text("lead_id").references(() => leads.id, { onDelete: "set null" }),
client_id: text("client_id").references(() => clients.id, { onDelete: "set null" }),
offer_macro_id: text("offer_macro_id")
.notNull()
.references(() => offer_macros.id, { onDelete: "restrict" }),
title: text("title"),
// Intero ProposalContent assemblato. Validato a runtime via Zod
// (src/lib/proposal/schema.ts); jsonb gestito da postgres-js (parse automatico).
content: jsonb("content").notNull(),
model: text("model"), // es. "claude-opus-4-8"
state: text("state").notNull().default("draft"), // draft | published | accepted | rejected
selected_tier: text("selected_tier"), // 'A' | 'B' | 'C' — CHECK a livello SQL (migration 0010)
accepted_at: timestamp("accepted_at", { withTimezone: true }), // immutable once set
client_email: text("client_email"),
client_notes: text("client_notes"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// ============ RELATIONS ============
export const clientsRelations = relations(clients, ({ many }) => ({
projects: many(projects),
transcripts: many(clientTranscripts),
proposals: many(proposals),
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
@@ -654,6 +690,7 @@ export const leadsRelations = relations(leads, ({ many }) => ({
activities: many(activities),
reminders: many(reminders),
transcripts: many(clientTranscripts),
proposals: many(proposals),
}));
export const activitiesRelations = relations(activities, ({ one }) => ({
@@ -682,6 +719,15 @@ export const quotesRelations = relations(quotes, ({ one, many }) => ({
quoteItems: many(quote_items),
}));
export const proposalsRelations = relations(proposals, ({ one }) => ({
lead: one(leads, { fields: [proposals.lead_id], references: [leads.id] }),
client: one(clients, { fields: [proposals.client_id], references: [clients.id] }),
offerMacro: one(offer_macros, {
fields: [proposals.offer_macro_id],
references: [offer_macros.id],
}),
}));
// ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============
export type Client = typeof clients.$inferSelect;
@@ -739,4 +785,6 @@ export type NewActivity = typeof activities.$inferInsert;
export type Reminder = typeof reminders.$inferSelect;
export type NewReminder = typeof reminders.$inferInsert;
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
export type Proposal = typeof proposals.$inferSelect;
export type NewProposal = typeof proposals.$inferInsert;
+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>;