src/components/admin/offers/OfferListClient.tsx (+ Nuova Offerta)
createOfferMacro server action (Plan 03)
useTransition + router.refresh on success, new card appears in grid
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.
// 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 <form action={fn}> 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.
exportasyncfunctioncreateOfferMacro(formData: FormData):Promise<void>;
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 (
<div>
<OfferListClient cards={cards} categoryOptions={options.categoria} />
</div>
);
}
```
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` `<Badge>` or a styled `<span>`.
- 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
`<form>` with one text `<input name="internal_name" required placeholder="Nome
offerta...">` 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...".
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
</threat_model>
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).