feat(12-04): build OfferListClient with category filter, archive toggle, card grid

- Category filter chips ("Tutti" + dynamic categoryOptions), active/inactive
  styling per UI-SPEC (#1A463C / #e5e7eb)
- "Mostra offerte archiviate" toggle (default off), filters via useMemo
- Responsive card grid (1/2/3 cols) linking to /admin/offers/[id]/edit,
  with category badge and "Archiviata" indicator
- "+ Nuova Offerta" inline form calling createOfferMacro via useTransition
  + router.refresh()
- Empty state with "Nessuna offerta" / "Inizia creando la tua prima offerta"
This commit is contained in:
2026-06-15 10:25:15 +02:00
parent 68dc1b605b
commit 7df4b9c74a
@@ -0,0 +1,158 @@
"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";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
export function OfferListClient({
cards,
categoryOptions,
}: {
cards: OfferListCard[];
categoryOptions: string[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newOfferName, setNewOfferName] = useState("");
const filteredCards = useMemo(() => {
return cards.filter(
(c) =>
(activeCategory === null || c.category === activeCategory) &&
(showArchived || !c.is_archived)
);
}, [cards, activeCategory, showArchived]);
function handleCreateSubmit(formData: FormData) {
startTransition(async () => {
await createOfferMacro(formData);
setShowCreateForm(false);
setNewOfferName("");
router.refresh();
});
}
const createCta = (
<div className="flex flex-col items-end gap-2">
<Button
type="button"
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
onClick={() => setShowCreateForm((v) => !v)}
>
+ Nuova Offerta
</Button>
{showCreateForm && (
<form
action={handleCreateSubmit}
className="flex items-center gap-2 bg-white border border-[#e5e7eb] rounded-lg p-2"
>
<Input
name="internal_name"
required
placeholder="Nome offerta..."
value={newOfferName}
onChange={(e) => setNewOfferName(e.target.value)}
className="h-9 w-48"
/>
<Button
type="submit"
size="sm"
disabled={isPending}
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
>
Crea
</Button>
</form>
)}
</div>
);
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
{createCta}
</div>
{/* Filter row */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveCategory(null)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
activeCategory === null
? "border-[#1A463C] text-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a]"
}`}
>
Tutti
</button>
{categoryOptions.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
activeCategory === category
? "border-[#1A463C] text-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a]"
}`}
>
{category}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-[#71717a]">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="h-4 w-4 rounded border-[#e5e7eb]"
/>
Mostra offerte archiviate
</label>
</div>
{/* Card grid / empty state */}
{filteredCards.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-[#e5e7eb] bg-white py-16 text-center">
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">Nessuna offerta</h3>
<p className="text-sm text-[#71717a]">Inizia creando la tua prima offerta</p>
<div className="mt-2">{createCta}</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCards.map((card) => (
<Link
key={card.id}
href={`/admin/offers/${card.id}/edit`}
className="cursor-pointer rounded-lg border border-[#e5e7eb] bg-white p-4 transition-shadow hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
>
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">{card.internal_name}</h3>
{card.description && (
<p className="mt-1 line-clamp-2 text-sm text-[#71717a]">{card.description}</p>
)}
<div className="mt-3 flex items-center gap-2">
{card.category && <Badge variant="secondary">{card.category}</Badge>}
{card.is_archived && (
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}