190 lines
7.4 KiB
TypeScript
190 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { dxGet } from "./dx";
|
||
|
||
type Cat = { id: string | number; name: string };
|
||
type Sub = { id: string | number; name: string; bg_cat_id: string | number };
|
||
type Entry = {
|
||
submission_id: string | number;
|
||
title?: string | null;
|
||
brand?: string | null;
|
||
model?: string | null;
|
||
thumb?: { id: string; filename_disk?: string } | null;
|
||
bg_cat_id?: { id: string | number; name?: string } | string | number | null;
|
||
bg_sub_cat_id?: { id: string | number; name?: string } | string | number | null;
|
||
price_min?: number | null;
|
||
price_max?: number | null;
|
||
};
|
||
|
||
function assetUrl(idOrPath?: string) {
|
||
// Prefer Directus asset URL if you expose one publicly; fallback to /api/dx/assets/:id
|
||
if (!idOrPath) return "";
|
||
if (/^https?:\/\//i.test(idOrPath)) return idOrPath;
|
||
return `/api/dx/assets/${idOrPath}`;
|
||
}
|
||
|
||
export default function BuyingGuideList({ embedded = true }: { embedded?: boolean }) {
|
||
const [cats, setCats] = useState<Cat[]>([]);
|
||
const [subs, setSubs] = useState<Sub[]>([]);
|
||
const [entries, setEntries] = useState<Entry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [catId, setCatId] = useState<string | number | "all">("all");
|
||
const [subId, setSubId] = useState<string | number | "all">("all");
|
||
const [q, setQ] = useState("");
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [catRes, subRes] = await Promise.all([
|
||
dxGet<Cat[]>("items/bg_cat", { fields: "id,name", limit: 500, sort: "name" }),
|
||
dxGet<Sub[]>("items/bg_sub_cat", { fields: "id,name,bg_cat_id", limit: 1000, sort: "name" }),
|
||
]);
|
||
setCats(catRes);
|
||
setSubs(subRes);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
setLoading(true);
|
||
try {
|
||
// Build filter
|
||
const filter: any = {};
|
||
if (catId !== "all") filter.bg_cat_id = { _eq: catId };
|
||
if (subId !== "all") filter.bg_sub_cat_id = { _eq: subId };
|
||
if (q.trim()) {
|
||
filter._or = [
|
||
{ title: { _icontains: q } },
|
||
{ brand: { _icontains: q } },
|
||
{ model: { _icontains: q } },
|
||
];
|
||
}
|
||
|
||
const data = await dxGet<Entry[]>("items/bg_entries", {
|
||
fields: [
|
||
"submission_id",
|
||
"title",
|
||
"brand",
|
||
"model",
|
||
"price_min",
|
||
"price_max",
|
||
"thumb.id",
|
||
"bg_cat_id.id",
|
||
"bg_cat_id.name",
|
||
"bg_sub_cat_id.id",
|
||
"bg_sub_cat_id.name",
|
||
].join(","),
|
||
filter: JSON.stringify(filter),
|
||
limit: 48,
|
||
sort: "brand,model,title",
|
||
});
|
||
|
||
setEntries(data);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [catId, subId, q]);
|
||
|
||
const subsForCat = useMemo(
|
||
() => (catId === "all" ? subs : subs.filter(s => String(s.bg_cat_id) === String(catId))),
|
||
[subs, catId]
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Controls */}
|
||
<div className="flex flex-wrap gap-3 items-end">
|
||
<div className="flex flex-col">
|
||
<label className="text-xs text-zinc-400 mb-1">Category</label>
|
||
<select
|
||
className="rounded-md border bg-background px-2 py-1"
|
||
value={catId}
|
||
onChange={e => { setCatId(e.target.value === "all" ? "all" : e.target.value); setSubId("all"); }}
|
||
>
|
||
<option value="all">All</option>
|
||
{cats.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex flex-col">
|
||
<label className="text-xs text-zinc-400 mb-1">Subcategory</label>
|
||
<select
|
||
className="rounded-md border bg-background px-2 py-1"
|
||
value={subId}
|
||
onChange={e => setSubId(e.target.value === "all" ? "all" : e.target.value)}
|
||
>
|
||
<option value="all">All</option>
|
||
{subsForCat.map(s => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-[220px]">
|
||
<label className="text-xs text-zinc-400 mb-1 block">Search</label>
|
||
<input
|
||
className="w-full rounded-md border bg-background px-3 py-1.5"
|
||
placeholder="brand, model, title…"
|
||
value={q}
|
||
onChange={e => setQ(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Results */}
|
||
{loading ? (
|
||
<div className="text-sm text-zinc-400">Loading…</div>
|
||
) : entries.length === 0 ? (
|
||
<div className="text-sm text-zinc-400">No results.</div>
|
||
) : (
|
||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||
{entries.map(e => {
|
||
const thumbId = (e.thumb as any)?.id as string | undefined;
|
||
return (
|
||
<a
|
||
key={e.submission_id}
|
||
href={`/buying-guide/${e.submission_id}`}
|
||
className="rounded-lg border hover:bg-muted/40 p-3 block"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title="Open product in new tab"
|
||
>
|
||
<div className="aspect-[4/3] rounded-md border overflow-hidden mb-2 grid place-items-center bg-muted">
|
||
{thumbId ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={assetUrl(thumbId)}
|
||
alt=""
|
||
className="object-cover w-full h-full"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
) : (
|
||
<div className="text-xs text-zinc-500">No image</div>
|
||
)}
|
||
</div>
|
||
<div className="font-medium truncate">
|
||
{e.brand ? `${e.brand} ` : ""}{e.model || e.title || "Untitled"}
|
||
</div>
|
||
<div className="text-xs text-zinc-400 truncate">
|
||
{(e.bg_cat_id as any)?.name || ""}{(e.bg_sub_cat_id as any)?.name ? ` • ${(e.bg_sub_cat_id as any).name}` : ""}
|
||
</div>
|
||
{(e.price_min || e.price_max) && (
|
||
<div className="text-sm mt-1">
|
||
{e.price_min && e.price_max && e.price_min !== e.price_max
|
||
? `$${e.price_min.toLocaleString()}–$${e.price_max.toLocaleString()}`
|
||
: `$${(e.price_min || e.price_max)!.toLocaleString()}`}
|
||
</div>
|
||
)}
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|