buyingguide test fix

This commit is contained in:
makearmy 2025-10-15 16:50:32 -04:00
parent e7a0c91a37
commit 73bba240ab

View file

@ -1,190 +1,342 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { dxGet } from "./dx";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
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}`;
interface Entry {
id: number;
product_make: string;
product_model: string;
product_price?: string;
review_overview_text?: string;
bg_entry_sub_cat?: number;
bg_entry_cat?: number;
index?: {
id: string;
filename_disk?: string;
type?: string;
};
header?: {
id: string;
filename_disk?: string;
type?: string;
};
}
export default function BuyingGuideList({ embedded = true }: { embedded?: boolean }) {
const [cats, setCats] = useState<Cat[]>([]);
const [subs, setSubs] = useState<Sub[]>([]);
interface SubCategory {
id: number;
name: string;
bg_entry_cat?: number;
}
interface Category {
id: number;
name: string;
}
export default function BuyingGuidePage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [entries, setEntries] = useState<Entry[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [subcategories, setSubcategories] = useState<SubCategory[]>([]);
const [selectedCat, setSelectedCat] = useState("");
const [selectedSubCat, setSelectedSubCat] = useState("");
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);
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const fetchData = async () => {
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" }),
const [entriesRes, catRes, subCatRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_sub_cat?fields=id,name,bg_entry_cat&limit=-1`),
]);
setCats(catRes);
setSubs(subRes);
} finally {
const [entriesData, catData, subCatData] = await Promise.all([
entriesRes.json(),
catRes.json(),
subCatRes.json(),
]);
setEntries(entriesData?.data || []);
setCategories(catData?.data || []);
setSubcategories(subCatData?.data || []);
setLoading(false);
} catch (err) {
console.error("Error fetching data:", err);
setLoading(false);
}
})();
};
fetchData();
}, []);
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 normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
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",
});
const filtered = useMemo(() => {
const q = normalize(debouncedQuery);
return entries.filter((entry) => {
const catMatch = selectedCat ? entry.bg_entry_cat === parseInt(selectedCat) : true;
const subCatMatch = selectedSubCat ? entry.bg_entry_sub_cat === parseInt(selectedSubCat) : true;
const searchMatch = q
? [entry.product_make, entry.product_model, entry.review_overview_text].some((field) =>
normalize(field || "").includes(q)
)
: true;
return catMatch && subCatMatch && searchMatch;
});
}, [entries, debouncedQuery, selectedCat, selectedSubCat]);
setEntries(data);
} finally {
setLoading(false);
}
})();
}, [catId, subId, q]);
const filteredSubcategories = useMemo(() => {
return selectedCat
? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat))
: subcategories;
}, [subcategories, selectedCat]);
const subsForCat = useMemo(
() => (catId === "all" ? subs : subs.filter(s => String(s.bg_cat_id) === String(catId))),
[subs, catId]
);
const featuredEntry = useMemo(() => {
if (!entries.length) return null;
const randomIndex = Math.floor(Math.random() * entries.length);
return entries[randomIndex];
}, [entries]);
const secondFeaturedEntry = useMemo(() => {
if (entries.length < 2) return null;
let secondIndex = Math.floor(Math.random() * entries.length);
while (entries[secondIndex].id === featuredEntry?.id) {
secondIndex = Math.floor(Math.random() * entries.length);
}
return entries[secondIndex];
}, [entries, featuredEntry]);
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="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
gap: 1rem;
}
.entry-card {
display: flex;
background-color: #242424;
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
height: 150px;
}
.entry-image {
width: 150px;
height: 150px;
object-fit: cover;
}
.entry-content {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.truncate-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`}</style>
<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="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Buying Guide</h1>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedCat}
onChange={(e) => {
setSelectedCat(e.target.value);
setSelectedSubCat("");
}}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id.toString()}>
{cat.name}
</option>
))}
</select>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedSubCat}
onChange={(e) => setSelectedSubCat(e.target.value)}
>
<option value="">All Subcategories</option>
{filteredSubcategories.map((sub) => (
<option key={sub.id} value={sub.id.toString()}>
{sub.name}
</option>
))}
</select>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products by make, model, etc..."
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground mb-2">
Discover reviewed laser products and accessories.
</p>
<a
href="/"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</a>
</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"
{[featuredEntry, secondFeaturedEntry].map((entry, idx) => (
entry && (
<div key={idx} className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Featured Product</h2>
{entry.header?.filename_disk ? (
<Image
src={`https://forms.lasereverything.net/assets/${entry.header.filename_disk}`}
alt="Header image"
width={800}
height={100}
className="w-full h-[100px] object-cover mb-2 rounded-md"
unoptimized
/>
) : (
<div className="text-xs text-zinc-500">No image</div>
<div className="w-full h-[100px] bg-zinc-800 flex items-center justify-center text-zinc-400 text-sm rounded-md mb-2">
No Header
</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>
<Link
href={`/buying-guide/product/${entry.id}`}
className="text-accent font-semibold text-lg hover:underline"
>
{entry.product_make} {entry.product_model}
</Link>
{entry.product_price && (
<p className="text-sm text-white">Starting at {entry.product_price}</p>
)}
</a>
);
})}
<p className="text-sm text-muted-foreground mt-1">
{entry.review_overview_text?.slice(0, 140)}...
</p>
</div>
)
))}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Popular Categories</h2>
<ul className="text-sm space-y-1">
{categories.slice(0, 5).map((cat) => (
<li key={cat.id}>
<button
onClick={() => {
setSelectedCat(cat.id.toString());
setSelectedSubCat("");
}}
className="text-accent hover:underline"
>
{cat.name}
</button>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{entries.slice(0, 3).map((e) => (
<li key={e.id}>
<Link href={`/buying-guide/product/${e.id}`} className="text-accent hover:underline">
{e.product_make} {e.product_model}
</Link>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
<p className="text-sm text-muted-foreground">
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations. Use the filters and search to find what youre looking for!
</p>
</div>
</div>
<hr className="my-8 border-border" />
{loading ? (
<p className="text-muted">Loading entries...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No entries found.</p>
) : (
<div className="card-grid">
{filtered.map((entry) => {
const filename = entry.index?.filename_disk;
return (
<div key={entry.id} className="entry-card">
{filename ? (
<Image
src={`https://forms.lasereverything.net/assets/${filename}`}
alt={`${entry.product_make} ${entry.product_model}`}
width={150}
height={150}
className="entry-image"
unoptimized
/>
) : (
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">
No Image
</div>
)}
<div className="entry-content">
<div>
<p className="text-sm font-medium text-muted-foreground truncate-title">
{entry.product_make}
</p>
<Link
href={`/buying-guide/product/${entry.id}`}
className="text-lg font-semibold text-accent underline truncate-title"
title={entry.product_model}
>
{entry.product_model}
</Link>
{entry.product_price !== undefined && (
<p className="text-sm text-foreground mt-1 font-medium">
Starting at {entry.product_price}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{entry.review_overview_text?.slice(0, 120)}...
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}