332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
import Link from "next/link";
|
||
import Image from "next/image";
|
||
|
||
interface Entry {
|
||
id: number | string;
|
||
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;
|
||
};
|
||
}
|
||
|
||
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);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||
return () => clearTimeout(timer);
|
||
}, [query]);
|
||
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
try {
|
||
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`),
|
||
]);
|
||
|
||
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();
|
||
}, []);
|
||
|
||
const normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
|
||
|
||
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]);
|
||
|
||
const filteredSubcategories = useMemo(() => {
|
||
return selectedCat ? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat)) : subcategories;
|
||
}, [subcategories, selectedCat]);
|
||
|
||
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]);
|
||
|
||
// Build a URL that sets ?product=<id> while preserving any existing params.
|
||
const makeProductHref = (id: number | string) => {
|
||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||
sp.set("product", String(id));
|
||
return `?${sp.toString()}`;
|
||
};
|
||
|
||
return (
|
||
<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="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>
|
||
|
||
{[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="w-full h-[100px] bg-zinc-800 flex items-center justify-center text-zinc-400 text-sm rounded-md mb-2">
|
||
No Header
|
||
</div>
|
||
)}
|
||
<Link href={makeProductHref(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>}
|
||
<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={makeProductHref(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 you’re 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={makeProductHref(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>
|
||
);
|
||
}
|