--- phase: 12 plan: 04 type: execute wave: 3 depends_on: [1, 3] files_modified: - src/app/admin/offers/page.tsx - src/components/admin/offers/OfferListClient.tsx autonomous: true requirements: [OFFER-18] must_haves: truths: - "Admin sees a card grid of all offers, each showing internal name, short description, and category chip" - "Admin can filter the card grid by category (Tutti / Entry Offer / Signature Offer / Retainer Offer, derived dynamically from existing category values)" - "Admin can toggle 'Mostra offerte archiviate' to show/hide archived offers (hidden by default)" - "Admin can create a new offer via '+ Nuova Offerta', which creates an offer_macros row and the new card appears in the grid" - "Each card links to the Plan 05 editor route /admin/offers/[id]/edit" artifacts: - path: "src/app/admin/offers/page.tsx" provides: "Server component: fetches getOfferListCards() + getOfferFieldOptions(), renders OfferListClient" - path: "src/components/admin/offers/OfferListClient.tsx" provides: "Client component: category filter chips, archive toggle, card grid, '+ Nuova Offerta' button calling createOfferMacro" exports: ["OfferListClient"] key_links: - from: "src/components/admin/offers/OfferListClient.tsx" to: "/admin/offers/[id]/edit" via: "next/link Link per card" pattern: "admin/offers/\\$\\{.*\\}/edit" - from: "src/components/admin/offers/OfferListClient.tsx (+ Nuova Offerta)" to: "createOfferMacro server action (Plan 03)" via: "useTransition + router.refresh on success, new card appears in grid" pattern: "createOfferMacro" --- Redesign `/admin/offers` (currently a flat macro/micro CRUD list — the legacy page) into the Phase 12 UI-SPEC card-grid list: filterable by category chips, archive toggle, "+ Nuova Offerta" CTA, each card linking to the new per-offer editor route (`/admin/offers/[id]/edit`, built in Plan 05). Purpose: Deliver OFFER-18 (category-filterable, archive-aware offer list) per the locked UI-SPEC (`12-UI-SPEC.md` section 1) and D-1 (full editor list+detail, CSV import deferred). Output: `src/app/admin/offers/page.tsx` rewritten as the new list page; `src/components/admin/offers/OfferListClient.tsx` (new) implementing filter/grid/CTA logic. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md @src/app/admin/catalog/CatalogSearch.tsx @src/components/admin/AdminSidebar.tsx ```typescript export type OfferListCard = Pick; export async function getOfferListCards(): Promise; export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] }; export async function getOfferFieldOptions(): Promise; ``` ```typescript // Creates a new offer_macros row from FormData { internal_name, public_name?, description?, category? } // public_name defaults to internal_name if omitted. Returns void (revalidates /admin/offers). // NOTE: createOfferMacro does NOT return the new row's id (FormData server actions can't // easily return values to client callers in the
pattern). For the // "+ Nuova Offerta" flow, Task 2 calls createOfferMacro via startTransition + FormData, // then router.refresh() — the new card appears in the grid; admin clicks it to open the // editor (Plan 05). No need to extend Plan 03's contract. export async function createOfferMacro(formData: FormData): Promise; ``` Task 1: Rewrite /admin/offers page.tsx as the new list page entry point src/app/admin/offers/page.tsx - src/app/admin/offers/page.tsx (current — full file, ~239 lines, the legacy macro/micro CRUD page being replaced) - src/app/admin/catalog/page.tsx (pattern: thin server component, `Promise.all` fetch, delegates to a client component for interactivity) - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1: Offer List Page layout, header, filter row, card grid, empty state, copywriting contract table) Replace the entire contents of `src/app/admin/offers/page.tsx` with a thin server component matching the `catalog/page.tsx` pattern: ```typescript import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries"; import { OfferListClient } from "@/components/admin/offers/OfferListClient"; export const revalidate = 0; export default async function OffersPage() { const [cards, options] = await Promise.all([ getOfferListCards(), getOfferFieldOptions(), ]); return (
); } ``` The legacy macro/micro/offer_services CRUD UI previously on this page (forms for `createMacro`/`createMicro`/`createOfferService`, `ServiceCheckboxList`, etc.) is REMOVED from this route per D-1 (full editor redesign) and the UI-SPEC (section 1 — no legacy CRUD forms in the new list page). The underlying `offer_macros`/`offer_micros` legacy actions (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`, `updateMicroOfferServices`) remain exported from `actions.ts` (Plan 03 did not remove them) but are no longer referenced from any page — this is intentional dead-export cleanup deferred to a future phase if needed (do NOT delete the exports now, only routes/usages, to avoid breaking `npx tsc --noEmit` if anything else imports them; grep confirms nothing else does, but leaving the exports is zero-risk and out of this plan's scope to remove).
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20 - `src/app/admin/offers/page.tsx` is a server component importing `getOfferListCards`, `getOfferFieldOptions`, and `OfferListClient` - `grep -c "ServiceCheckboxList\|createMacro\|createMicro\|createOfferService" src/app/admin/offers/page.tsx` == 0 (legacy forms removed from this page) - `npx tsc --noEmit` passes (will fail until Task 2 creates `OfferListClient` — run combined verification after both tasks) page.tsx is a thin server component delegating to OfferListClient with the new data shape.
Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA src/components/admin/offers/OfferListClient.tsx - src/app/admin/catalog/CatalogSearch.tsx (client component pattern: "use client", useState filter state, useMemo derived list, search input) - src/components/ui/button.tsx, src/components/ui/badge.tsx, src/components/ui/input.tsx (exported component signatures — use these for CTA button, category chips/badges, "+ Nuova Offerta" form input) - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1: exact layout ASCII diagram, color tokens #1A463C/#DEF168/#dc2626/#71717a/#e5e7eb, card structure, empty state copy, filter chip active/inactive styling, copywriting contract table for all literal strings) Create `src/components/admin/offers/OfferListClient.tsx`: ```typescript "use client"; import { useState, useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { createOfferMacro } from "@/app/admin/offers/actions"; import type { OfferListCard } from "@/lib/offer-queries"; export function OfferListClient({ cards, categoryOptions, }: { cards: OfferListCard[]; categoryOptions: string[]; }) { // ... implementation below } ``` Implementation requirements (per UI-SPEC section 1 + copywriting contract table): 1. **State**: `activeCategory: string | null` (null = "Tutti"), `showArchived: boolean` (default `false`), `showCreateForm: boolean` + a controlled text input for the new offer's `internal_name`. 2. **Filter row**: - Chip row: "Tutti" + one chip per `categoryOptions` value. Active chip: border `#1A463C`, text `#1A463C`. Inactive: border `#e5e7eb`, text `#71717a`. 8px gap (`gap-2`). - "Mostra offerte archiviate" checkbox + label (`#71717a` text), toggles `showArchived`. 3. **Filtered list** (`useMemo`): `cards.filter(c => (activeCategory === null || c.category === activeCategory) && (showArchived || !c.is_archived))`. 4. **Card grid**: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4`. Each card: - `Link href={`/admin/offers/${card.id}/edit`}` wrapping a `div` with white bg, `border border-[#e5e7eb] rounded-lg p-4 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] cursor-pointer transition-shadow`. - `internal_name` as h3 (16px/600). - `description` (if present) as body text, `line-clamp-2`, `#71717a`. - Category badge: small chip showing `category` (if present) — reuse `src/components/ui/badge.tsx` `` or a styled ``. - If `is_archived`: red "Archiviata" badge (text `#dc2626`). 5. **Empty state**: if `filteredCards.length === 0`, show centered block: heading "Nessuna offerta" (h3), body "Inizia creando la tua prima offerta" (`#71717a`), and the "+ Nuova Offerta" CTA (same as header). 6. **"+ Nuova Offerta" CTA** (top-right of page header, "Offerte" h2 title to its left): - Clicking "+ Nuova Offerta" toggles `showCreateForm`, revealing a small inline `` with one text `` and a submit button "Crea". - On submit: `startTransition(async () => { await createOfferMacro(formData); setShowCreateForm(false); router.refresh(); })`. After `router.refresh()`, the new card appears in the grid — do NOT attempt to auto-navigate to the editor in this plan (acceptable per UI-SPEC: admin clicks the new card to open the editor). Button style: bg `#1A463C`, text white, per UI-SPEC. 7. **Page header**: "Offerte" (h2, 20px/600, `#1a1a1a`) + "+ Nuova Offerta" button, `flex items-center justify-between`. All literal copy strings come from the UI-SPEC copywriting contract table verbatim (Italian): "Offerte", "+ Nuova Offerta", "Mostra offerte archiviate", "Tutti", "Nessuna offerta", "Inizia creando la tua prima offerta", "Archiviata", "Crea", "Nome offerta...". cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx 2>&1 | tail -30 - `npx tsc --noEmit` passes with zero errors (page.tsx + OfferListClient.tsx + rest of project) - `npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx` reports zero errors - `grep -c "OfferListCard" src/components/admin/offers/OfferListClient.tsx` >= 1 - `grep -c "createOfferMacro" src/components/admin/offers/OfferListClient.tsx` >= 1 - `grep -c "admin/offers/\${" src/components/admin/offers/OfferListClient.tsx` >= 1 (card -> editor link) - `grep -c "Mostra offerte archiviate\|Nessuna offerta\|Nuova Offerta" src/components/admin/offers/OfferListClient.tsx` >= 3 (UI-SPEC copy present) OfferListClient renders the category-filterable, archive-aware card grid with working "+ Nuova Offerta" creation; page.tsx + OfferListClient.tsx typecheck and lint clean.
## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin browser -> createOfferMacro (via "+ Nuova Offerta") | Free-text `internal_name` input crosses into a new `offer_macros` row | | Admin browser -> /admin/offers/[id]/edit links | Card `id` values are server-fetched (not user input) — links are safe by construction | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-12-10 | Tampering | "+ Nuova Offerta" inline form | accept | `createOfferMacro` (Plan 03) already calls `requireAdmin()` + Zod-validates `internal_name` non-empty; this plan only adds the UI trigger, no new validation surface | | T-12-11 | Information Disclosure | Offer list exposes `description`/`category`/`is_archived` for all offers | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); no change to access control in this plan | 1. `npx tsc --noEmit` passes for the full project. 2. `npx eslint` clean for both modified/created files. 3. `/admin/offers` renders a card grid sourced from `getOfferListCards()`, with category filter chips from `getOfferFieldOptions().categoria` and an archive toggle (default off). 4. "+ Nuova Offerta" creates a new `offer_macros` row via `createOfferMacro` and the new card appears after `router.refresh()`. 5. Each card links to `/admin/offers/${id}/edit` (Plan 05's route — may 404 until Plan 05 lands, but the link target is correct). - `/admin/offers` matches the UI-SPEC section 1 layout: header + CTA, category filter chips, archive toggle, card grid, empty state. - OFFER-18 satisfied: list filterable by category, archive toggle hides archived offers by default. - No legacy macro/micro CRUD forms remain on `/admin/offers`. After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md`