---
phase: 12
plan: 04
type: execute
wave: 4
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
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
`
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`.