Compare commits
2 Commits
009466ac18
...
fdcc938252
| Author | SHA1 | Date | |
|---|---|---|---|
| fdcc938252 | |||
| 86c86cd420 |
+11
-11
@@ -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,122 @@
|
||||
# Phase 21: Agente AI — generazione preventivo - Context
|
||||
|
||||
**Gathered:** 2026-06-20
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
L'admin apre il dettaglio di un lead, seleziona un'offerta e lancia la generazione: Claude legge i transcript del lead e i dati dell'offerta (tutti e 3 i tier) e produce una bozza di preventivo strutturata in sezioni fisse. L'admin rivede il testo in una textarea e salva la bozza prima che diventi pubblicabile in Phase 22.
|
||||
|
||||
**In scope:** prompt engineering + chiamata API Claude + bozza strutturata in sezioni + form di revisione/editing + tabella `proposals` nel DB
|
||||
**Out of scope:** pagina pubblica `/preventivo/[slug]` (Phase 22), invio email (Phase 22), accettazione/rifiuto cliente (Phase 22)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Struttura output AI
|
||||
|
||||
- **D-01:** Claude produce sezioni strutturate fisse, non testo libero. Il prompt specifica esattamente i blocchi da riempire.
|
||||
- **D-02:** Le sezioni del preventivo generato sono, in ordine:
|
||||
1. **Situazione Attuale** — Claude descrive la situazione del cliente basandosi sul contenuto dei transcript (cosa sta vivendo, i pain emersi nelle call)
|
||||
2. **La Proposta** — Presentazione personalizzata dell'offerta: perché questa offerta è giusta per questo cliente specifico
|
||||
3. **Opzione A / Opzione B / Opzione C** — Una sotto-sezione per ogni tier, con lista dei servizi inclusi e prezzo pubblico del tier
|
||||
4. **Prossimi Passi** — Call to action finale (Claude scrive testo standard, admin può editare)
|
||||
- **D-03:** L'admin **non sceglie un tier** prima della generazione. Claude genera tutti e 3 i tier in un unico documento. Il cliente leggerà le 3 opzioni e sceglierà.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
Le seguenti aree non sono state discusse — Claude ha flessibilità:
|
||||
|
||||
- **Builder location:** Entry point nel LeadDetail esistente (`src/components/admin/leads/LeadDetail.tsx`) — bottone "Genera Preventivo" che apre un modal o sezione inline con selezione offerta + trigger generazione. È il posto più naturale perché i transcript del lead sono già in contesto.
|
||||
- **UX generazione:** Fire-and-wait con spinner. Nessun streaming. L'admin clicca "Genera", vede uno stato di caricamento, poi il testo appare. Più semplice e affidabile per il caso d'uso (generazione ~5-15 sec).
|
||||
- **Editor bozza:** Textarea non-formattata. Il preventivo generato appare in una `<textarea>` grande che l'admin può editare liberamente prima di salvare. Nessun rich text editor in questa fase.
|
||||
- **SDK:** `@anthropic-ai/sdk` (pacchetto ufficiale Anthropic). Nessun Vercel AI SDK o LangChain.
|
||||
- **Modello:** `claude-sonnet-4-6` (modello attivo del progetto).
|
||||
- **Storage:** Nuova tabella `proposals` nel DB seguendo i pattern esistenti: `id` (nanoid PK), `lead_id` (FK → leads, nullable cascade), `offer_id` (FK → offer_macros), `content` (text NOT NULL — il testo completo della bozza), `slug` (text unique — nanoid, per il link pubblico Phase 22), `status` (text: `draft` | `published`), `created_at` (timestamp with timezone).
|
||||
- **Migration:** SQL a mano come `0010_proposals.sql` (drizzle-kit generate è rotto). Applicare a prod via SSH prima di pushare il codice.
|
||||
- **Server action / API route:** Server Action Next.js per il trigger di generazione (pattern coerente col resto del progetto). La chiamata Anthropic avviene lato server.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Requirements & Roadmap
|
||||
- `.planning/ROADMAP.md` — Phase 21 goal, success criteria (AI-01/AI-02), dipendenze (Phase 20 + DB offerte Phase 11/12)
|
||||
- `.planning/REQUIREMENTS.md` — AI-01 (generazione), AI-02 (revisione bozza)
|
||||
|
||||
### Contesto Phase 20 (transcript — input dell'AI)
|
||||
- `.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md` — Decisioni schema `client_transcripts`, pattern query, struttura dati che l'AI deve leggere
|
||||
- `src/lib/lead-service.ts` — `getTranscripts(leadId)` → restituisce array con `{id, title, content, call_date}` ordinati per data DESC; il campo `content` è testo integrale, non troncato
|
||||
|
||||
### Dati offerta (input dell'AI)
|
||||
- `src/lib/offer-queries.ts` — `getOfferEditorData(macroId)` → restituisce i dati completi dell'offerta inclusi tier A/B/C con servizi e `public_price`; questa è la funzione da usare per costruire il contesto offerta nel prompt
|
||||
- `src/app/admin/offers/actions.ts` — pattern server actions per offerte (riferimento per nuovo layer proposals)
|
||||
|
||||
### Schema & Migrations
|
||||
- `src/db/schema.ts` — pattern tabelle esistenti: `leads`, `activities`, `clientTranscripts`, `offer_macros` — usare per definire `proposals`
|
||||
- `src/db/migrations/0009_client_transcripts.sql` — ultimo esempio SQL migration a mano (struttura e convenzioni da replicare per `0010_proposals.sql`)
|
||||
|
||||
### UI & Integration Points
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — struttura UI dove va aggiunto il trigger "Genera Preventivo" e la sezione bozza
|
||||
- `src/app/admin/leads/[id]/page.tsx` — page con `Promise.all` per fetch parallele (pattern da seguire, aggiungere fetch proposals)
|
||||
- `src/app/admin/leads/actions.ts` — pattern `requireAdmin` guard + `revalidatePath` (da seguire per le nuove actions proposals)
|
||||
|
||||
### Sicurezza & Architettura
|
||||
- `CLAUDE.md` → sezione Architecture Constraints: `quote_items` MAI esposti via client API; la nuova tabella `proposals` segue lo stesso principio — `content` non esposto via client token senza consenso esplicito Phase 22
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `getTranscripts(leadId)` in `src/lib/lead-service.ts` — pronta, restituisce `content` integrale; concatenare i transcript in ordine cronologico per il prompt AI
|
||||
- `getOfferEditorData(macroId)` in `src/lib/offer-queries.ts` — restituisce tier A/B/C con array di servizi e `public_price` per tier; mappare questi dati nel prompt con nome servizi + prezzi
|
||||
- `nanoid` — già in uso nel progetto per PK; riusare per `proposals.id` e `proposals.slug`
|
||||
- `requireAdmin()` — già in ogni server action, obbligatorio anche per le nuove actions proposals
|
||||
|
||||
### Established Patterns
|
||||
- **Migration a mano**: `CREATE TABLE IF NOT EXISTS`, tipi Postgres espliciti, FK con `ON DELETE CASCADE` (o `SET NULL` se nullable). NON usare `drizzle-kit generate`.
|
||||
- **Prod-first migration**: la migration `0010_proposals.sql` DEVE essere applicata a prod via SSH+docker exec (`ssh -L 54321:localhost:54321 root@178.104.27.55`) PRIMA di pushare il codice che la referenzia.
|
||||
- **Server Actions con revalidatePath**: ogni mutation chiama `revalidatePath()` sul path del lead.
|
||||
- **Promise.all fetch**: `page.tsx` del lead usa `await Promise.all([...])` — aggiungere `getProposals(id)` nello stesso array.
|
||||
|
||||
### Integration Points
|
||||
- `src/app/admin/leads/[id]/page.tsx` — aggiungere `getProposals(id)` nel `Promise.all`, passare `proposals` a `<LeadDetail />`
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — aggiungere sezione "Preventivo" con bottone trigger + form selezione offerta + textarea bozza
|
||||
- `src/db/schema.ts` — aggiungere definizione `proposals` + relazioni Drizzle
|
||||
- `src/lib/lead-service.ts` (o nuovo `src/lib/proposal-service.ts`) — `generateProposal(leadId, offerId)`, `getProposals(leadId)`, `saveProposal(id, content)`
|
||||
- `.env.local` — aggiungere `ANTHROPIC_API_KEY` (richiede configurazione in Coolify per produzione)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Il preventivo è multi-tier by design: non c'è una scelta del tier nella UI di generazione. Claude scrive 3 opzioni (A/B/C) in un solo documento, il cliente legge e sceglie.
|
||||
- La sezione "Opzione A/B/C" deve mostrare i nomi dei servizi inclusi in modo leggibile (non JSON grezzo), e il `public_price` del tier in modo prominente.
|
||||
- La sezione "Situazione Attuale" è la parte più personalizzata — Claude deve pescare dai transcript specifici insights sul cliente, non frasi generiche. Il prompt deve guidare su questo.
|
||||
- L'admin in fase di review vede l'intero testo del preventivo in una `<textarea>` di altezza generosa (tipo `min-h-96`) e può editare liberamente prima di salvare.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Scelta tier nel preventivo:** eventualmente l'admin potrebbe scegliere un tier da presentare → Phase 22 o fase futura post v2.2.
|
||||
- **Streaming output AI:** Claude scrive in real-time → futura ottimizzazione UX se la latenza diventa un problema.
|
||||
- **Versioning bozze:** mantenere storico delle generazioni per un lead → futura fase.
|
||||
- **Agente multi-step con tool_use:** architettura più sofisticata dove Claude chiama tool API invece di ricevere dati pre-imbarcati nel prompt → futura fase.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 21-Agente AI — generazione preventivo*
|
||||
*Context gathered: 2026-06-20*
|
||||
@@ -0,0 +1,40 @@
|
||||
# Phase 21: Discussion Log
|
||||
|
||||
**Session:** 2026-06-20
|
||||
**Areas discussed:** 1 of 4 identified (Struttura preventivo)
|
||||
|
||||
---
|
||||
|
||||
## Area: Struttura preventivo
|
||||
|
||||
### Q1 — Tipo di output AI
|
||||
- **Opzioni:** Testo libero personalizzato / Sezioni strutturate fisse / Ibrido intro+sezioni
|
||||
- **Scelta:** Sezioni strutturate fisse
|
||||
- **Note:** L'admin e il cliente hanno bisogno di un documento prevedibile — le sezioni fisse rendono più facile sia il prompt engineering che l'editing successivo.
|
||||
|
||||
### Q2 — Quali sezioni
|
||||
- **Opzioni (multi-select):** Situazione Attuale / La Proposta / Cosa è Incluso / Investimento + Prossimi Passi
|
||||
- **Scelta:** Tutte e 4 le sezioni
|
||||
- **Note:** L'utente ha aggiunto "penso che creo un agente apposta per questa parte" — chiarito che si trattava di un'altra sessione Claude Code, nessuna interferenza con questo progetto.
|
||||
|
||||
### Q3 — Tier: singolo o tutti e 3
|
||||
- **Opzioni:** Un solo tier scelto dall'admin / Tutti e 3 i tier in un documento
|
||||
- **Scelta:** Tutti e 3 i tier in un documento
|
||||
- **Note:** Il preventivo presenta opzioni A/B/C al cliente, che sceglie leggendo.
|
||||
|
||||
---
|
||||
|
||||
## Aree non discusse (Claude's discretion)
|
||||
|
||||
- **Builder location:** Entry point nel LeadDetail esistente
|
||||
- **UX generazione:** Fire-and-wait con spinner (no streaming)
|
||||
- **Editor bozza:** Textarea plain text
|
||||
|
||||
---
|
||||
|
||||
## Idee deferred
|
||||
|
||||
- Scelta tier singolo nel preventivo (post v2.2)
|
||||
- Streaming output AI (ottimizzazione futura)
|
||||
- Versioning bozze
|
||||
- Agente multi-step con tool_use
|
||||
@@ -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 |
|
||||
Generated
+63
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
|
||||
personalizzata. La generazione richiede 30–60 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>
|
||||
);
|
||||
}
|
||||
@@ -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'offerta. L'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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 01–05) 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"
|
||||
}>, // 3–5 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[] // 3–5 obiettivi misurabili
|
||||
},
|
||||
deliverables: {
|
||||
deliverables: string[], // output principali (tier-agnostic)
|
||||
outOfScope: string[] // 3–5 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
|
||||
}> // 4–10 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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 07–09) — 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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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()), // 3–5 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>;
|
||||
Reference in New Issue
Block a user