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:
@@ -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 (1–N 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'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'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'opzione, la prima sessione viene pianificata entro la settimana successiva.
|
||||
Tutto parte da lì.
|
||||
</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">
|
||||
“{problem.quote}”
|
||||
</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'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'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 & 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'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">
|
||||
“{t.quote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user