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