mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -18,37 +18,40 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||
import { PLACEMENT_MAP } from "@/data/placement.data";
|
||||
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default function AdvertisementList() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [placementFilter, setPlacementFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// TODO(ads-3): Filters are not actually filtering — the "status" filter in
|
||||
// particular compares against the stored `status` column, but status is only
|
||||
// recomputed on read (see deriveStatus() in
|
||||
// controllers/admin/advertisements.controller.js) and never persisted back
|
||||
// to the DB. An ad that lapsed to "expired" still has status="active" in
|
||||
// the row, so filtering by status here misses/matches the wrong rows.
|
||||
// Needs either persisting the derived status on write/read, or filtering
|
||||
// server-side using the same derivation logic. Also verify type/placement
|
||||
// filters actually round-trip once ads-1/ads-2 land.
|
||||
useEffect(() => {
|
||||
const buildFilters = () => {
|
||||
const filters = [];
|
||||
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
||||
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
|
||||
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
|
||||
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
|
||||
if (typeFilter !== "all") filters.push({ id: "type", value: typeFilter });
|
||||
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
|
||||
if (search.trim()) filters.push({ id: "headline", value: search.trim() });
|
||||
return filters;
|
||||
};
|
||||
|
||||
fetchAdvertisements({ page: 1, limit: 24, filters });
|
||||
// Single source of truth for fetching — filter setters below always pair
|
||||
// their state update with setPage(1) in the same handler so this only
|
||||
// ever fires once per change (no separate "reset page" effect racing it).
|
||||
useEffect(() => {
|
||||
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [typeFilter, placementFilter, statusFilter, search]);
|
||||
}, [typeFilter, statusFilter, search, page]);
|
||||
|
||||
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
|
||||
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
|
||||
const runSearch = () => { setSearch(searchInput); setPage(1); };
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -77,7 +80,7 @@ export default function AdvertisementList() {
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
||||
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
|
||||
@@ -101,7 +104,7 @@ export default function AdvertisementList() {
|
||||
|
||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<Select value={typeFilter} onValueChange={handleTypeFilter}>
|
||||
<SelectTrigger className="w-[150px] bg-background">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
@@ -113,20 +116,7 @@ export default function AdvertisementList() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
|
||||
<Select value={placementFilter} onValueChange={setPlacementFilter}>
|
||||
<SelectTrigger className="w-[220px] bg-background">
|
||||
<SelectValue placeholder="All placements" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All placements</SelectItem>
|
||||
{PLACEMENTS.map((p) => (
|
||||
<SelectItem key={p.key} value={p.key}>{p.pageLabel} — {p.slotLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||
<SelectTrigger className="w-[150px] bg-background">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
@@ -138,31 +128,23 @@ export default function AdvertisementList() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* TODO(ads-5): Verify this already satisfies the spec — search only
|
||||
fires on button click / Enter (`search` state, not `searchInput`,
|
||||
drives the fetch effect above), typing alone does not refetch.
|
||||
Looks done already; double-check then mark complete. */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
|
||||
<div className="relative flex-1">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search advertisements..."
|
||||
className="pl-8 bg-background"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
|
||||
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={runSearch} aria-label="Search">
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO(ads-8): Add pagination controls for this grid — currently
|
||||
always fetches page 1 / limit 24 with no way to reach further
|
||||
pages (see `pagination` from useAdvertisements, already returned
|
||||
by the API but unused here). */}
|
||||
{/* ── Grid ───────────────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -171,17 +153,28 @@ export default function AdvertisementList() {
|
||||
) : advertisements.length === 0 ? (
|
||||
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{advertisements.map((ad) => (
|
||||
<AdvertisementCard
|
||||
key={ad.advertisement_id}
|
||||
ad={ad}
|
||||
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
||||
onArchive={() => handleArchive(ad.advertisement_id)}
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{advertisements.map((ad) => (
|
||||
<AdvertisementCard
|
||||
key={ad.advertisement_id}
|
||||
ad={ad}
|
||||
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
||||
onArchive={() => handleArchive(ad.advertisement_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-background rounded-lg border">
|
||||
<TablePagination
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
rowCount={advertisements.length}
|
||||
recordLabel="advertisement"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +291,7 @@ function EmptyState({ onCreate }) {
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No advertisements yet</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
|
||||
</div>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="size-4" />
|
||||
|
||||
Reference in New Issue
Block a user