Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:47 +08:00
parent 71f758fe0b
commit fa92d924f4
50 changed files with 2202 additions and 2623 deletions
@@ -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" />