640f986967
Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14 user decision: 5-plan wave structure (schema migration -> prod apply checkpoint -> query/action layer -> list + detail editor UI), OFFER-12 CSV/Notion import deferred.
820 lines
40 KiB
Markdown
820 lines
40 KiB
Markdown
# Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico - Research
|
||
|
||
**Researched:** 2026-06-14
|
||
**Domain:** Offer composition UI + additive schema (tier designations, tag dimensions, public pricing)
|
||
**Confidence:** HIGH
|
||
|
||
## Summary
|
||
|
||
Phase 12 is a **complete UI + schema redesign** of offer composition, re-scoped from "drag-drop composer + CSV import" to a **tier-matrix offer editor** (A/B/C tiers with checkbox matrix, live totals, tag dimensions, structured transformation promise, public pricing per tier). The design follows Phase 11's database-view patterns (inline edit, polymorphic tagging, Notion-style option pools) and extends the catalog with additive schema only.
|
||
|
||
**Key finding:** The critical constraint is **additive schema only** (Data Safety LOCKED). This means:
|
||
- ✅ Extend `offer_macros` and `offer_micros` with new columns
|
||
- ✅ Create **new junction table** `offer_tier_services` (tier → `services.id`, not `offer_services.id`)
|
||
- ✅ Extend `tags` table with polymorphic entity_type scoping
|
||
- ❌ NEVER modify legacy `offer_micro_services` (points to deprecated `offer_services`)
|
||
- ❌ NEVER drop/rename columns on existing tables in the locked set
|
||
|
||
**Primary recommendation:**
|
||
1. Create schema migrations **by hand** (drizzle-kit generate is broken; follow 0003-0007 pattern)
|
||
2. Extend `offer_macros`: `description`, `is_archived`, `categoria_single`, `ticket_single`, `transformation_promise_json` (structured fields)
|
||
3. Extend `offer_micros`: `tier_letter` (A|B|C), `public_price`
|
||
4. Add **new junction table** `offer_tier_services`: `(tier_id, service_id)` as PK
|
||
5. Tag 4 dimensions using **polymorphic entity_type scoping**: `"offer_macros"`, `"offer_macros.ticket"`, `"offer_macros.tipo"`, `"offer_macros.obiettivo"`
|
||
6. Query layer: fetch offers with categories, tags, and service matrix pre-computed
|
||
7. Editor UI: use Phase 11 patterns (`EditableCell`, `OptionMultiSelect`, `requireAdmin` server actions, inline validation via Zod)
|
||
|
||
---
|
||
|
||
## User Constraints (from CONTEXT.md)
|
||
|
||
### Locked Decisions
|
||
- **D-1 (Scope):** Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura.
|
||
- **D-2 (UX composizione tier):** **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe.
|
||
- **D-3 (Fonte servizi):** Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`.
|
||
- **D-4 (Filtro servizi per categoria):** Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica.
|
||
- **D-5 (Prezzo):** Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo".
|
||
- **D-6 (Schema):** **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire.
|
||
|
||
### Claude's Discretion
|
||
- **Tag dimension model:** Use polymorphic `entity_type` scoping or add additive `dimension` column? Recommend: polymorphic entity_type (`"offer_macros.categoria"`, `"offer_macros.ticket"`, etc.) to avoid schema change and reuse Phase 11 pattern.
|
||
- **Junction table naming:** `offer_tier_services` suggested; planner may choose alternative.
|
||
- **Tier letter persistence:** Store in `offer_micros.tier_letter` or compute from row position? Recommend: store explicitly for clarity.
|
||
- **Public price per tier:** Manual input or formula? Locked as manual (D-5).
|
||
|
||
### Deferred Ideas (OUT OF SCOPE)
|
||
- **Import CSV / import da Notion** (OFFER-12, rimandato a fase futura su decisione utente 2026-06-14).
|
||
- **Sezioni analitiche Notion** (psicologia/rating/performance — OFFER-14, v2).
|
||
- **Drag&drop composer** (originally in Phase 12 scope per UI-SPEC 12-UI-SPEC.md, now replaced by checkbox matrix).
|
||
- **Collegamento offerta→preventivo/pagamento** (Proposal AI, Phase 16/17).
|
||
|
||
---
|
||
|
||
## Phase Requirements
|
||
|
||
| ID | Description | Research Support |
|
||
|----|-------------|------------------|
|
||
| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live | Schema: `offer_micros.tier_letter`, new junction `offer_tier_services`; UI: checkbox matrix with live total calculation (client-side sum per tier) |
|
||
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11 | Tag pattern: polymorphic `tags` table with scoped `entity_type` (e.g. `"offer_macros.categoria"`, `"offer_macros.ticket"`) — reuse Phase 11 pattern; addServiceOption/removeServiceOption actions |
|
||
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Schema: `offer_micros.public_price` (numeric); UI: EditableCell input per tier row |
|
||
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Schema: `offer_macros.transformation_promise_json` (JSONB); structured form in editor with 5 text inputs |
|
||
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Schema: `offer_macros.is_archived` (boolean); UI: category chip filter + "Mostra archiviate" toggle |
|
||
|
||
---
|
||
|
||
## Architectural Responsibility Map
|
||
|
||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||
|------------|-------------|----------------|-----------|
|
||
| Offer list rendering (cards, filters) | Frontend Server (SSR) | — | Server-side query fetch (offer list + tags + counts), hydrate React component for filter UI |
|
||
| Offer editor (form + matrix) | Browser / Frontend | Frontend Server | Browser state (tier matrix checkboxes, live total); form submission to server action |
|
||
| Live tier total calculation | Browser / Frontend | — | Deterministic sum of selected service prices per tier; no server round-trip per toggle |
|
||
| Tag management (add/remove/rename) | API / Backend | Frontend | Server actions (`addOfferTag`, `removeOfferTag`, `renameOfferTag` — requireAdmin-guarded) |
|
||
| Service filtering by offer category | Frontend Server (SSR) | — | Query layer: fetch services filtered by category, return in hydrated props |
|
||
| Transformation promise validation | API / Backend | — | Zod schema validation on offer save |
|
||
| Schema migrations | Database / Infrastructure | — | Hand-written SQL (drizzle-kit broken); applies to prod via SSH+docker exec before code push |
|
||
|
||
---
|
||
|
||
## Standard Stack
|
||
|
||
### Core (Locked from CLAUDE.md)
|
||
| Library | Version | Purpose | Why Standard |
|
||
|---------|---------|---------|--------------|
|
||
| Next.js | 16 App Router | Framework with SSR, server actions, revalidatePath | Project standard |
|
||
| Neon Postgres | Latest | Managed Postgres | Project standard |
|
||
| Drizzle ORM | Latest | Type-safe SQL with relations | Project standard; hand-write migrations (drizzle-kit broken) |
|
||
| Auth.js v4 | v4 | Session-based auth, `/admin/*` middleware | Project standard, requireAdmin pattern used throughout Phase 11 |
|
||
| Tailwind v4 | v4 | CSS utility framework | Project standard |
|
||
| shadcn/ui | Latest | Headless component library (Button, Input, Dialog, Badge, Table) | Phase 11 established; re-export from @/components/ui |
|
||
| Zod | Latest | Runtime schema validation (forms, server action inputs) | Phase 11 established (`serviceSchema`, `catalogFieldSchema`, etc.) |
|
||
|
||
### Supporting (Reuse Phase 11 patterns)
|
||
| Library | Version | Purpose | When to Use |
|
||
|---------|---------|---------|-------------|
|
||
| react-hook-form | Latest | Uncontrolled form state (optional; Phase 11 uses mostly FormData) | Only if structured form (5-field transformation promise) requires form state |
|
||
| Lucide React | Latest | Icon library | Drag handle, trash icon, filter chips (Phase 11 pattern) |
|
||
|
||
### NOT Used (Deferred)
|
||
| Instead of | Could Use | Why Not Used |
|
||
|-----------|-----------|--------------|
|
||
| `@dnd-kit/core` + `@dnd-kit/sortable` | Native drag-and-drop API | Originally in scope (D-2 mentions "@dnd-kit usato solo eventualmente per riordinare le righe"); checkbox matrix is simpler and preferred UX (D-2 locked). Row reorder optional; defer to Phase 12 plan. |
|
||
| papaparse / CSV import | Native JS text parsing | CSV import (OFFER-12) is deferred; not in scope for Phase 12. |
|
||
|
||
---
|
||
|
||
## Architecture Patterns
|
||
|
||
### System Architecture Diagram
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ /admin/offers │
|
||
│ (OffersList + OfferEditor) │
|
||
└────────────┬──────────────────────────────────┬───────────────────┘
|
||
│ │
|
||
▼ ▼
|
||
┌────────────────────┐ ┌──────────────────────┐
|
||
│ OfferListQuery │ │ OfferEditorQuery │
|
||
│ (SSR page.tsx) │ │ (SSR page.tsx) │
|
||
│ │ │ │
|
||
│ • getAllOffers() │ │ • getOfferDetail() │
|
||
│ • filter by cat │ │ • getCategoriesOpts │
|
||
│ • getTagOptions() │ │ • getServicesForOffer│
|
||
│ │ │ • getServicesByCateg │
|
||
└────────┬───────────┘ └──────────┬───────────┘
|
||
│ │
|
||
▼ ▼
|
||
┌────────────────────┐ ┌──────────────────────┐
|
||
│ OfferListUI │ │ OfferEditorUI │
|
||
│ (React Client) │ │ (React Client) │
|
||
│ │ │ │
|
||
│ • Chip filter cat │ │ • Offer header form │
|
||
│ • Archive toggle │ │ • 4-dim tag inputs │
|
||
│ • Card list │ │ • Tier matrix │
|
||
│ • "+New" button │ │ • Live totals/tier │
|
||
│ • Edit/delete │ │ • Transform promise │
|
||
└────────┬───────────┘ │ • [Salva]/[Annulla] │
|
||
│ └──────────┬───────────┘
|
||
│ │
|
||
└───────────┬───────────────────┘
|
||
│
|
||
┌──────────▼──────────┐
|
||
│ Server Actions │
|
||
│ (actions.ts) │
|
||
│ │
|
||
│ • createOffer() │
|
||
│ • updateOffer() │
|
||
│ • deleteOffer() │
|
||
│ • updateOfferTiers()│
|
||
│ • addOfferTag() │
|
||
│ • removeOfferTag() │
|
||
│ • renameOfferTag() │
|
||
└──────────┬──────────┘
|
||
│
|
||
┌──────────▼────────────────────┐
|
||
│ Database Layer (Drizzle) │
|
||
│ │
|
||
│ Tables: │
|
||
│ • offer_macros (+) │
|
||
│ • offer_micros (+) │
|
||
│ • offer_tier_services (NEW) │
|
||
│ • services (Phase 11) │
|
||
│ • tags (polymorphic scoped) │
|
||
└───────────────────────────────┘
|
||
```
|
||
|
||
**Data flow:**
|
||
1. **List view:** SSR page fetches `getAllOffers()` → query returns macros + micro counts + tag counts per offer, filtered by category/archive
|
||
2. **Editor view:** SSR page fetches `getOfferDetail(macroId)` → query returns macro + all micros with tier letters + services for matrix + tag pools
|
||
3. **Service matrix source:** `getServicesForOfferCategory(categoryId)` → filters catalog by category field (set in Phase 11)
|
||
4. **Live total:** Browser state (checkboxes) → sum prices client-side (no server call per toggle)
|
||
5. **Save:** Form submission → server action validates (Zod) → updates offer_macros + offer_micros + offer_tier_services + tags → revalidatePath
|
||
|
||
### Recommended Project Structure
|
||
|
||
```
|
||
src/
|
||
├── app/admin/offers/
|
||
│ ├── page.tsx # OffersList (SSR, list + filters)
|
||
│ ├── [macroId]/
|
||
│ │ └── page.tsx # OfferEditor (SSR, detail + matrix editor)
|
||
│ ├── actions.ts # Server actions (CRUD, tags)
|
||
│ └── layout.tsx # Shared layout
|
||
├── lib/
|
||
│ └── offer-queries.ts # Query layer (new/extended)
|
||
├── components/admin/offers/
|
||
│ ├── OffersList.tsx # List component (filters, cards)
|
||
│ ├── OfferEditor.tsx # Editor component (form + matrix)
|
||
│ ├── OfferMatrix.tsx # Tier×Service checkbox matrix
|
||
│ ├── TransformationPromiseForm.tsx # 5-field structured form
|
||
│ ├── OfferTagInput.tsx # Multi-select tag input
|
||
│ └── OfferCategoryFilter.tsx # Category chip filter
|
||
└── db/
|
||
└── schema.ts # (Extension additions only)
|
||
```
|
||
|
||
### Pattern 1: Additive Schema (Data Safety LOCKED)
|
||
|
||
**What:** Extend existing offer tables with new columns; create new junction tables for new relationships; NEVER modify or drop existing columns in locked tables.
|
||
|
||
**When to use:** Every schema change in Phase 12. The rule is absolute: `clients`, `projects`, `payments`, `phases`, `offer_macros`, `offer_micros`, `offer_micro_services` (legacy), `quote_items` are all READ-ONLY at the column/table level.
|
||
|
||
**Example migrations (hand-written SQL):**
|
||
```sql
|
||
-- Migration 0008: offer_tier_schema.sql (following 0003-0007 convention)
|
||
|
||
-- Extend offer_macros with new columns
|
||
ALTER TABLE offer_macros ADD COLUMN description TEXT;
|
||
ALTER TABLE offer_macros ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT false;
|
||
ALTER TABLE offer_macros ADD COLUMN transformation_promise_json JSONB;
|
||
|
||
-- Extend offer_micros with tier designations and public pricing
|
||
ALTER TABLE offer_micros ADD COLUMN tier_letter TEXT CHECK (tier_letter IN ('A', 'B', 'C'));
|
||
ALTER TABLE offer_micros ADD COLUMN public_price NUMERIC(10, 2);
|
||
|
||
-- NEW junction table: tier ↔ services (replaces legacy offer_micro_services reference)
|
||
CREATE TABLE offer_tier_services (
|
||
tier_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||
service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||
PRIMARY KEY (tier_id, service_id),
|
||
INDEX offer_tier_services_tier_idx (tier_id)
|
||
);
|
||
|
||
-- Tags for offers are scoped via entity_type (no schema change needed)
|
||
-- entity_type values: "offer_macros", "offer_macros.ticket", "offer_macros.tipo", "offer_macros.obiettivo"
|
||
```
|
||
|
||
**Apply to production:**
|
||
```bash
|
||
# SSH to prod host, then:
|
||
docker exec -it clienthub-db psql -U $DB_USER -d $DB_NAME < src/db/migrations/0008_offer_tier_schema.sql
|
||
# BEFORE pushing code that references offer_tier_services
|
||
```
|
||
|
||
---
|
||
|
||
### Pattern 2: Polymorphic Tags (Phase 11 Extended)
|
||
|
||
**What:** The `tags` table uses `entity_type` + `entity_id` to scope tags across different entities. Phase 11 scoped tags as `"services"` and `"services.pacchetto"`. Phase 12 extends this to offers with 4 dimensions.
|
||
|
||
**Design choice: Polymorphic entity_type scoping (Recommended)**
|
||
```sql
|
||
-- tags table (unchanged structure from Phase 11)
|
||
-- entity_type values determine which dimension:
|
||
-- "offer_macros" → categoria (single-select, though stored in tags table)
|
||
-- "offer_macros.ticket" → ticket (single-select, though stored in tags table)
|
||
-- "offer_macros.tipo" → tipo (multi-select)
|
||
-- "offer_macros.obiettivo" → obiettivo (multi-select)
|
||
|
||
-- Query: all categoria tags for offer
|
||
SELECT name FROM tags WHERE entity_type = 'offer_macros' AND entity_id = '12345'
|
||
|
||
-- Query: all ticket tags for offer
|
||
SELECT name FROM tags WHERE entity_type = 'offer_macros.ticket' AND entity_id = '12345'
|
||
```
|
||
|
||
**Why this design:**
|
||
- Reuses Phase 11 unique constraint: `UNIQUE(entity_type, entity_id, name)` — no schema change needed
|
||
- Scopes tag pools naturally: categoria pool separate from ticket pool (no collisions)
|
||
- Matches Phase 11 code pattern (`TAG_ENTITY = "services"`, `PACCHETTO_ENTITY = "services.pacchetto"`)
|
||
- Avoids adding a new `dimension` column (avoids 5th schema change)
|
||
|
||
**Drizzle constants (in src/lib/admin-queries.ts):**
|
||
```typescript
|
||
const OFFER_CATEGORIA_ENTITY = "offer_macros";
|
||
const OFFER_TICKET_ENTITY = "offer_macros.ticket";
|
||
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
||
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
||
```
|
||
|
||
**Server action pattern (reuse from Phase 11):**
|
||
```typescript
|
||
export async function addOfferTag(
|
||
macroId: string,
|
||
dimension: "categoria" | "ticket" | "tipo" | "obiettivo",
|
||
tagName: string
|
||
) {
|
||
await requireAdmin();
|
||
const entity_type = {
|
||
categoria: OFFER_CATEGORIA_ENTITY,
|
||
ticket: OFFER_TICKET_ENTITY,
|
||
tipo: OFFER_TIPO_ENTITY,
|
||
obiettivo: OFFER_OBIETTIVO_ENTITY,
|
||
}[dimension];
|
||
|
||
await db.insert(tags).values({
|
||
entity_type,
|
||
entity_id: macroId,
|
||
name: tagName.trim(),
|
||
}).onConflictDoNothing();
|
||
|
||
revalidatePath("/admin/offers");
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Pattern 3: Tier Matrix Composition (Checkbox Pattern)
|
||
|
||
**What:** Instead of drag-and-drop, use a simple checkbox matrix: rows are services, columns are tiers (A/B/C), cells are checkboxes.
|
||
|
||
**When to use:** Building the OfferMatrix UI component.
|
||
|
||
**Example UX:**
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ Servizi inclusi nella tua offerta │
|
||
├─────────────────────────────┬──────┬──────┬──────┬──────────┤
|
||
│ Servizio │ Tier │ Tier │ Tier │ Prezzo │
|
||
│ │ A │ B │ C │ unitario │
|
||
├─────────────────────────────┼──────┼──────┼──────┼──────────┤
|
||
│ Personal Branding Audit │ [x] │ [ ] │ [ ] │ €500 │
|
||
│ Brand Strategy Workshop │ [x] │ [x] │ [ ] │ €300 │
|
||
│ Logo Design │ [ ] │ [x] │ [x] │ €800 │
|
||
├─────────────────────────────┼──────┼──────┼──────┼──────────┤
|
||
│ Totale servizi (auto) │ €800 │ €1.1K│ €800 │ │
|
||
│ Prezzo pubblico (manuale) │ [ €900 ] │ [ €1.2K ] │ [ €900 ] │
|
||
└─────────────────────────────┴──────┴──────┴──────┴──────────┘
|
||
```
|
||
|
||
**React component pattern:**
|
||
```typescript
|
||
// OfferMatrix.tsx
|
||
type OfferMatrixProps = {
|
||
services: Service[]; // filtered by offer category
|
||
tiers: Array<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
|
||
initialAssignments: Map<string, string[]>; // Map<tier_id, service_ids>
|
||
onChange: (assignments: Map<string, string[]>) => void;
|
||
};
|
||
|
||
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) {
|
||
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
|
||
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
|
||
);
|
||
|
||
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
|
||
const next = new Map(assignments);
|
||
const tierSet = new Set(next.get(tierId) ?? []);
|
||
if (checked) tierSet.add(serviceId);
|
||
else tierSet.delete(serviceId);
|
||
next.set(tierId, tierSet);
|
||
setAssignments(next);
|
||
onChange(next); // parent updates form state for submission
|
||
};
|
||
|
||
const totals = useMemo(() => {
|
||
return tiers.map(tier => ({
|
||
tier_id: tier.id,
|
||
total: Array.from(assignments.get(tier.id) ?? [])
|
||
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
|
||
.reduce((a, b) => a + b, 0),
|
||
}));
|
||
}, [assignments, services, tiers]);
|
||
|
||
return (
|
||
<table className="w-full border-collapse">
|
||
<thead>
|
||
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
|
||
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
|
||
{tiers.map(tier => (
|
||
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
|
||
Tier {tier.tier_letter}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{services.map(service => (
|
||
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
|
||
<td className="px-4 py-3 font-medium">{service.name}</td>
|
||
<td className="text-center px-4 py-3 text-[#71717a]">
|
||
€{parseFloat(service.unit_price).toFixed(2)}
|
||
</td>
|
||
{tiers.map(tier => (
|
||
<td key={tier.id} className="text-center px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
|
||
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
|
||
className="cursor-pointer"
|
||
/>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Data flow on save:**
|
||
1. User toggles checkbox → state updates → live total recalculates (client-side)
|
||
2. User clicks "Salva" → form submission with Map<tier_id, Set<service_id>>
|
||
3. Server action converts to array, validates via Zod, then:
|
||
- Delete all rows in `offer_tier_services` where tier_id IN (this offer's tiers)
|
||
- Insert new rows: `INSERT INTO offer_tier_services (tier_id, service_id) VALUES ...`
|
||
4. Revalidate `/admin/offers` → user sees updated matrix
|
||
|
||
---
|
||
|
||
### Pattern 4: Live Total Calculation (Client-Side)
|
||
|
||
**What:** Sum of selected service prices per tier, updated immediately on checkbox toggle.
|
||
|
||
**When to use:** OfferMatrix component and any form showing price aggregates.
|
||
|
||
**Implementation:**
|
||
```typescript
|
||
const computeTierTotal = (selectedServiceIds: string[], services: Service[]): number => {
|
||
return services
|
||
.filter(s => selectedServiceIds.includes(s.id))
|
||
.reduce((sum, s) => sum + parseFloat(s.unit_price), 0);
|
||
};
|
||
|
||
// On checkbox toggle:
|
||
const tierTotal = computeTierTotal(Array.from(checkedIds), services);
|
||
setTotals(prev => ({ ...prev, [tierId]: tierTotal }));
|
||
```
|
||
|
||
**Why client-side:**
|
||
- Deterministic (sum is pure function of selected items + prices)
|
||
- No server round-trip per toggle (better UX)
|
||
- Prices sourced from Phase 11 catalog (immutable, already in props)
|
||
- Validation on save (server-side Zod schema ensures data integrity)
|
||
|
||
---
|
||
|
||
### Pattern 5: Server Actions with requireAdmin (Phase 11 Reuse)
|
||
|
||
**What:** Use `getServerSession(authOptions)` to guard server actions; throw on non-admin.
|
||
|
||
**When to use:** All mutation operations.
|
||
|
||
**Example:**
|
||
```typescript
|
||
// src/app/admin/offers/actions.ts
|
||
|
||
async function requireAdmin() {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session) throw new Error("Non autorizzato");
|
||
}
|
||
|
||
const updateOfferTiersSchema = z.object({
|
||
macro_id: z.string().min(1),
|
||
tiers: z.array(
|
||
z.object({
|
||
micro_id: z.string().min(1),
|
||
tier_letter: z.enum(["A", "B", "C"]),
|
||
public_price: z.coerce.number().min(0),
|
||
service_ids: z.array(z.string()),
|
||
})
|
||
),
|
||
});
|
||
|
||
export async function updateOfferTiers(formData: FormData) {
|
||
await requireAdmin();
|
||
|
||
const parsed = updateOfferTiersSchema.safeParse({
|
||
macro_id: formData.get("macro_id"),
|
||
tiers: JSON.parse(formData.get("tiers_json") as string),
|
||
});
|
||
|
||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||
|
||
const { macro_id, tiers } = parsed.data;
|
||
|
||
// Update each tier
|
||
for (const tier of tiers) {
|
||
await db.update(offer_micros)
|
||
.set({
|
||
tier_letter: tier.tier_letter,
|
||
public_price: tier.public_price.toFixed(2),
|
||
})
|
||
.where(eq(offer_micros.id, tier.micro_id));
|
||
|
||
// Delete + re-insert services for this tier
|
||
await db.delete(offer_tier_services)
|
||
.where(eq(offer_tier_services.tier_id, tier.micro_id));
|
||
|
||
if (tier.service_ids.length > 0) {
|
||
await db.insert(offer_tier_services).values(
|
||
tier.service_ids.map(sid => ({ tier_id: tier.micro_id, service_id: sid }))
|
||
);
|
||
}
|
||
}
|
||
|
||
revalidatePath("/admin/offers");
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Anti-Patterns to Avoid
|
||
|
||
- **❌ Exposing offer details via client API:** Only `/admin/offers` (admin-only routes with session check). Never `/api/offers` (public endpoint). Offer composition is locked behind Auth.js.
|
||
- **❌ Computing cumulative_price at schema time:** It's a derived value (sum of selected services). Compute at **query time** (SQL SUM), not stored.
|
||
- **❌ Storing tier letter as computed rank:** Store as explicit column `tier_letter` so it's immutable and queryable.
|
||
- **❌ Re-using `offer_micro_services` for the new matrix:** It's legacy and points to deprecated `offer_services`. Always use the **new `offer_tier_services`** junction to `services.id`.
|
||
- **❌ Bulk-updating tier prices inline:** Each tier edit is a separate save. Avoid multi-tier batch updates unless required by spec.
|
||
|
||
---
|
||
|
||
## Don't Hand-Roll
|
||
|
||
| Problem | Don't Build | Use Instead | Why |
|
||
|---------|------------|-------------|-----|
|
||
| Multi-select tag input | Custom autocomplete with tag picker | `shadcn/ui` Badge + Input with onKeyDown, pattern from Phase 11 `OptionMultiSelect` | Phase 11 already solved this; reuse component + logic |
|
||
| Tier matrix checkbox logic | Custom matrix renderer | React state (useState) + table component | Simple enough; native HTML + React state is cleaner than a library |
|
||
| Live total calculation | Custom sum function | `reduce()` over selected services | Standard JS; no library needed for deterministic arithmetic |
|
||
| Tag management (add/remove/rename) | Custom CRUD logic | Mirror Phase 11 server actions: `addOfferTag`, `removeOfferTag`, `renameOfferTag` | Tag lifecycle is identical across entities; DRY principle |
|
||
| Offer schema validation | Regex or ad-hoc checks | Zod schema (`offerMacroSchema`, `offerMicroSchema`) | Type-safe, composable, reuses project standard |
|
||
| JSON transformation promise | String concatenation | JSONB column with typed read/write (Zod parse on read) | Structured data → queryable later; no parsing fragility |
|
||
|
||
**Key insight:** Phase 11 established production-grade patterns for inline edit, tagging, quick-add. Phase 12 must inherit these patterns exactly; the only new logic is tier matrix composition (fundamentally simpler than drag-drop).
|
||
|
||
---
|
||
|
||
## Code Examples
|
||
|
||
### Example 1: Query — Offer Detail with Tags & Services
|
||
|
||
```typescript
|
||
// src/lib/offer-queries.ts (new function)
|
||
|
||
export type OfferDetailWithServices = {
|
||
macro: OfferMacro & {
|
||
tags: {
|
||
categoria: string[];
|
||
ticket: string[];
|
||
tipo: string[];
|
||
obiettivo: string[];
|
||
}
|
||
};
|
||
micros: Array<OfferMicro & {
|
||
services: Array<{ id: string; name: string; unit_price: string }>;
|
||
cumulative_price: string;
|
||
}>;
|
||
};
|
||
|
||
export async function getOfferDetail(macroId: string): Promise<OfferDetailWithServices | null> {
|
||
const macro = await db.query.offer_macros.findFirst({ where: eq(offer_macros.id, macroId) });
|
||
if (!macro) return null;
|
||
|
||
// Fetch tags scoped to this offer (4 dimensions)
|
||
const tagRows = await db
|
||
.select({ entity_type: tags.entity_type, name: tags.name })
|
||
.from(tags)
|
||
.where(
|
||
and(
|
||
eq(tags.entity_id, macroId),
|
||
inArray(tags.entity_type, [
|
||
"offer_macros",
|
||
"offer_macros.ticket",
|
||
"offer_macros.tipo",
|
||
"offer_macros.obiettivo",
|
||
])
|
||
)
|
||
);
|
||
|
||
const tagsMap = {
|
||
categoria: tagRows.filter(t => t.entity_type === "offer_macros").map(t => t.name),
|
||
ticket: tagRows.filter(t => t.entity_type === "offer_macros.ticket").map(t => t.name),
|
||
tipo: tagRows.filter(t => t.entity_type === "offer_macros.tipo").map(t => t.name),
|
||
obiettivo: tagRows.filter(t => t.entity_type === "offer_macros.obiettivo").map(t => t.name),
|
||
};
|
||
|
||
// Fetch micros + services via NEW junction
|
||
const micros = await db.select().from(offer_micros).where(eq(offer_micros.macro_id, macroId));
|
||
const microIds = micros.map(m => m.id);
|
||
|
||
const servicesForMicros = await db
|
||
.select({
|
||
micro_id: offer_tier_services.tier_id,
|
||
service_id: offer_tier_services.service_id,
|
||
name: services.name,
|
||
unit_price: services.unit_price,
|
||
})
|
||
.from(offer_tier_services)
|
||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||
.where(inArray(offer_tier_services.tier_id, microIds));
|
||
|
||
// Compute cumulative totals at query time
|
||
const cumulPrices = await db
|
||
.select({
|
||
micro_id: offer_tier_services.tier_id,
|
||
cumulative: sql<string>`COALESCE(SUM(${services.unit_price}::numeric), 0)`,
|
||
})
|
||
.from(offer_tier_services)
|
||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||
.where(inArray(offer_tier_services.tier_id, microIds))
|
||
.groupBy(offer_tier_services.tier_id);
|
||
|
||
const cumulMap = new Map(cumulPrices.map(r => [r.micro_id, r.cumulative]));
|
||
const servicesMap = new Map<string, typeof servicesForMicros>();
|
||
for (const s of servicesForMicros) {
|
||
if (!servicesMap.has(s.micro_id)) servicesMap.set(s.micro_id, []);
|
||
servicesMap.get(s.micro_id)!.push(s);
|
||
}
|
||
|
||
return {
|
||
macro: { ...macro, tags: tagsMap },
|
||
micros: micros.map(m => ({
|
||
...m,
|
||
services: (servicesMap.get(m.id) ?? []).map(s => ({
|
||
id: s.service_id,
|
||
name: s.name,
|
||
unit_price: s.unit_price,
|
||
})),
|
||
cumulative_price: cumulMap.get(m.id) ?? "0",
|
||
})),
|
||
};
|
||
}
|
||
```
|
||
|
||
**Source:** [VERIFIED: Pattern from Phase 11 getAllServices + getCatalogWithMicros in offer-queries.ts lines 20–79]
|
||
|
||
---
|
||
|
||
### Example 2: Tier Matrix Component
|
||
|
||
```typescript
|
||
// src/components/admin/offers/OfferMatrix.tsx
|
||
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import type { Service, OfferMicro } from "@/db/schema";
|
||
|
||
type Props = {
|
||
services: Service[];
|
||
tiers: Array<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
|
||
initialAssignments: Map<string, string[]>;
|
||
onChange: (assignments: Map<string, string[]>) => void;
|
||
};
|
||
|
||
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: Props) {
|
||
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
|
||
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
|
||
);
|
||
|
||
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
|
||
const next = new Map(assignments);
|
||
const tierSet = new Set(next.get(tierId) ?? []);
|
||
if (checked) tierSet.add(serviceId);
|
||
else tierSet.delete(serviceId);
|
||
next.set(tierId, tierSet);
|
||
setAssignments(next);
|
||
onChange(next);
|
||
};
|
||
|
||
const totals = useMemo(() => {
|
||
return tiers.map(tier => ({
|
||
tier_id: tier.id,
|
||
total: Array.from(assignments.get(tier.id) ?? [])
|
||
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
|
||
.reduce((a, b) => a + b, 0),
|
||
}));
|
||
}, [assignments, services, tiers]);
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
|
||
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
|
||
{tiers.map(tier => (
|
||
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
|
||
Tier {tier.tier_letter}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{services.map(service => (
|
||
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
|
||
<td className="px-4 py-3 font-medium">{service.name}</td>
|
||
<td className="text-center px-4 py-3 text-[#71717a]">
|
||
€{parseFloat(service.unit_price).toFixed(2)}
|
||
</td>
|
||
{tiers.map(tier => (
|
||
<td key={tier.id} className="text-center px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
|
||
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
|
||
className="cursor-pointer"
|
||
/>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="mt-4 flex gap-8 font-semibold">
|
||
{totals.map(({ tier_id, total }) => (
|
||
<div key={tier_id}>
|
||
<span className="text-[#71717a]">Tier {tiers.find(t => t.id === tier_id)?.tier_letter}:</span>
|
||
<span className="ml-2">€{total.toFixed(2)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Source:** [VERIFIED: React hooks pattern + browser native checkbox]
|
||
|
||
---
|
||
|
||
## State of the Art
|
||
|
||
| Old Approach | Current Approach | When Changed | Impact |
|
||
|--------------|------------------|--------------|--------|
|
||
| `offer_micro_services` → `offer_services` | `offer_tier_services` → `services` (unified) | Phase 11 / Phase 12 | Single service catalog reduces duplication |
|
||
| Drag-and-drop composer (UI-SPEC.md, original scope) | Checkbox matrix (CONTEXT.md D-2, re-scoped 2026-06-14) | 2026-06-14 user decision | Simpler UX, faster implementation, better accessibility |
|
||
| Offer tiers as implicit rows | Explicit tier letters A/B/C in schema | Phase 12 (D-2) | Tier designations immutable, queryable, unambiguous |
|
||
| Manual text transformation promise | Structured JSON fields (5-field JSONB) | Phase 12 (OFFER-17) | Enables future analytics, templating, per-field editing |
|
||
| Shared `tags` table with single pool | Polymorphic entity_type scoping | Phase 11 / Phase 12 | Multiple tag dimensions stay separate; no collision risk |
|
||
|
||
---
|
||
|
||
## Assumptions Log
|
||
|
||
| # | Claim | Section | Risk if Wrong |
|
||
|---|-------|---------|---------------|
|
||
| A1 | Services already filtered by category field in Phase 11; offer editor can pre-filter matrix by `services.category` | D-4 | If category is not set or semantics differ, matrix shows all services instead of relevant ones |
|
||
| A2 | `cumulative_price` should be **computed at query time** (SQL SUM), not stored | Schema Design | If developer stores cumulative_price and forgets to update it, totals will be stale |
|
||
| A3 | Public pricing (public_price) is **always manual input**, never auto-calculated | D-5 | If business logic changes to auto-calculate public price, schema will need redesign |
|
||
| A4 | Tier letters are **always exactly 3 tiers** (A/B/C) per offer | D-2 | If future phases add variable tier counts, CHECK constraint will fail |
|
||
| A5 | **New `offer_tier_services` junction is not used elsewhere**; legacy `offer_micro_services` untouched | Schema Design | If downstream code accidentally reads offer_tier_services, queries will break |
|
||
| A6 | Phase 11's `services.category` and `services.fase` are not overloaded with other semantics | D-4 | If category/fase used differently elsewhere, offer editor filtering will be wrong |
|
||
|
||
---
|
||
|
||
## Open Questions
|
||
|
||
1. **Services all have category set? (Assumption A1)**
|
||
- **Recommendation:** In Phase 12 plan: verify all services have category; add default ("General") for nulls if needed
|
||
|
||
2. **Transformation promise: JSON or 5 columns? (OFFER-17)**
|
||
- **Recommendation:** Use **single JSONB column** for type safety and future queryability
|
||
|
||
3. **Tag dimensions: polymorphic entity_type or dimension column? (OFFER-15)**
|
||
- **Recommendation:** Use polymorphic entity_type (Design A) to avoid schema change, reuse Phase 11 pattern
|
||
|
||
4. **Row reordering via @dnd-kit? (D-2 optional mention)**
|
||
- **Recommendation:** Not in initial scope. If required, add `@dnd-kit/sortable` in Phase 12 plan
|
||
|
||
---
|
||
|
||
## Environment Availability
|
||
|
||
Not applicable — Phase 12 is code/schema only, no external service dependencies beyond existing Postgres/Neon.
|
||
|
||
---
|
||
|
||
## Security Domain
|
||
|
||
### Applicable ASVS Categories
|
||
|
||
| ASVS Category | Applies | Standard Control |
|
||
|---------------|---------|-----------------|
|
||
| V2 Authentication | Yes | Auth.js v4 session, requireAdmin guard on all server actions |
|
||
| V3 Session Management | Yes | Auth.js v4, immutable session, HTTPS-only cookies |
|
||
| V4 Access Control | Yes | `/admin/offers/*` routes check session; public routes don't expose offer data |
|
||
| V5 Input Validation | Yes | Zod schema on server action inputs; HTML5 numeric validation on prices |
|
||
| V6 Cryptography | No | Session token encrypted by Auth.js framework |
|
||
|
||
### Known Threat Patterns
|
||
|
||
| Pattern | STRIDE | Mitigation |
|
||
|---------|--------|-----------|
|
||
| CSRF on form submission | Tampering | Next.js server actions auto-include SameSite=Strict cookie |
|
||
| SQL injection via ORM | Tampering | Drizzle ORM uses parameterized queries; no string interpolation |
|
||
| Unvalidated price input | Tampering | Zod validates numeric range; coerced to number, min 0 |
|
||
| Privilege escalation | Elevation | requireAdmin() checks session; throws if missing |
|
||
| Data exposure via API | Information Disclosure | No public API; only `/admin/offers*` (private) access offer data |
|
||
| Race condition on service assignment | Tampering | PK constraint on junction prevents duplicates; last-write-wins acceptable for admin-only |
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
### Primary (HIGH confidence)
|
||
- **12-CONTEXT.md** — User decisions locked (D-1 through D-6)
|
||
- **ROADMAP.md Phase 12** — Requirements OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18; re-scoped 2026-06-14
|
||
- **REQUIREMENTS.md** — All phase requirements explicitly mapped; OFFER-12 deferred
|
||
- **src/db/schema.ts** — Current `offer_macros` (lines 262–271), `offer_micros` (lines 274–286), `tags` (lines 119–140)
|
||
- **src/app/admin/catalog/actions.ts** — Phase 11 patterns for tag management (lines 74–199)
|
||
- **src/lib/admin-queries.ts** — Query patterns (lines 366–449); polymorphic tagging (lines 889–927)
|
||
|
||
### Secondary (MEDIUM confidence)
|
||
- **CLAUDE.md** — Architecture Constraints (LOCKED): additive schema only, hand-write migrations, schema push via SSH+docker exec
|
||
- **12-UI-SPEC.md** — Original drag-drop scope; superseded by CONTEXT.md; kept for UI pattern reference
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Confidence breakdown:**
|
||
- **Standard stack:** HIGH — All locked in CLAUDE.md; versions current
|
||
- **Architecture:** HIGH — Schema additions minimal, scoped; query patterns mirror Phase 11
|
||
- **Additive schema:** HIGH — Data Safety locked; requires hand-written SQL (drizzle-kit broken)
|
||
- **Polymorphic tag model:** HIGH — Phase 11 establishes pattern; straightforward extension
|
||
- **Tier matrix UX:** MEDIUM — Checkbox pattern simpler than drag-drop; spec from CONTEXT.md user decision
|
||
- **Service category filtering:** MEDIUM — Assumes Phase 11 populated all services with category (Assumption A1)
|
||
- **Public pricing:** HIGH — Locked as manual input (D-5)
|
||
|
||
**Research date:** 2026-06-14
|
||
**Valid until:** 2026-06-21 (7 days — offer editor straightforward; schema stable; low tech drift risk)
|
||
|
||
---
|
||
|
||
*Research completed. Ready for Phase 12 planning.*
|