Files
2026-06-14 21:15:09 +02:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12 04 execute 4
1
3
src/app/admin/offers/page.tsx
src/components/admin/offers/OfferListClient.tsx
true
OFFER-18
truths artifacts key_links
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
path provides
src/app/admin/offers/page.tsx Server component: fetches getOfferListCards() + getOfferFieldOptions(), renders OfferListClient
path provides exports
src/components/admin/offers/OfferListClient.tsx Client component: category filter chips, archive toggle, card grid, '+ Nuova Offerta' button calling createOfferMacro
OfferListClient
from to via pattern
src/components/admin/offers/OfferListClient.tsx /admin/offers/[id]/edit next/link Link per card admin/offers/${.*}/edit
from to via pattern
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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
export type OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">;
export async function getOfferListCards(): Promise<OfferListCard[]>;

export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
// 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.
export async function createOfferMacro(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...".
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.

<threat_model>

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
</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).

<success_criteria>

  • /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. </success_criteria>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md`