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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user