makearmy-app/components/buying-guide/BuyingGuideList.tsx

190 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}