completely refactored utilities for direct rendering, killed iframes
This commit is contained in:
parent
12dd2c6c06
commit
f08a7456ee
37 changed files with 1824 additions and 1350 deletions
322
components/buying-guide/.bak/finder/page.tsx
Normal file
322
components/buying-guide/.bak/finder/page.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// app/buying-guide/finder/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { Answers, LaserType } from "@/lib/laser-finder";
|
||||
import { scoreAnswers, LASER_LABEL, TYPE_INFO } from "@/lib/laser-finder";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LaserFinderPage() {
|
||||
const [result, setResult] = useState<{
|
||||
top: LaserType[];
|
||||
score: Record<LaserType, number>;
|
||||
why: Record<LaserType, string[]>;
|
||||
} | null>(null);
|
||||
|
||||
const { register, handleSubmit, reset, watch } = useForm<Answers>({
|
||||
defaultValues: {
|
||||
materials: [],
|
||||
operations: [],
|
||||
part_size: "medium",
|
||||
detail: "medium",
|
||||
throughput: "medium",
|
||||
budget: "mid",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (vals: Answers) => {
|
||||
const { ranked, score, why } = scoreAnswers(vals);
|
||||
setResult({ top: ranked.slice(0, 2), score, why });
|
||||
// (Optional) later: POST vals to Directus for analytics
|
||||
};
|
||||
|
||||
const selectedMaterials = watch("materials");
|
||||
const selectedOps = watch("operations");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Laser Type Finder</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Answer a few questions and we’ll suggest the best laser <em>types</em> for your work
|
||||
with clear use-cases, materials, and cautions. No product pitches—just guidance.
|
||||
</p>
|
||||
|
||||
{!result && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Materials */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Materials (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["metals_bare", "Bare metals"],
|
||||
["metals_coated", "Coated/painted metals"],
|
||||
["plastics", "Plastics"],
|
||||
["wood_paper_leather", "Wood, paper, leather"],
|
||||
["glass_ceramic", "Glass / ceramic"],
|
||||
["stone", "Stone"],
|
||||
["textiles", "Textiles"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("materials")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedMaterials?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: choose at least one material for a better match.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Operations */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Typical operations (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["deep_mark_metal", "Deep mark on metal"],
|
||||
["color_mark_stainless", "Color mark stainless"],
|
||||
["fine_engraving", "Fine engraving (small features)"],
|
||||
["photo_engrave", "Photo engraving"],
|
||||
["cut_nonmetals_thick", "Cut thick non-metals (e.g., 6+ mm acrylic/wood)"],
|
||||
["cut_nonmetals_thin", "Cut thin non-metals"],
|
||||
["mark_coated", "Mark coated items"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("operations")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedOps?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: pick one or more to sharpen the recommendation.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Size / Detail / Speed / Budget */}
|
||||
<div className="grid sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Part size</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("part_size")}>
|
||||
<option value="small">Small (≤ 200 mm)</option>
|
||||
<option value="medium">Medium (≤ 600 mm)</option>
|
||||
<option value="large">Large (> 600 mm)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Detail</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("detail")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="micro">Micro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Throughput</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("throughput")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Budget</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("budget")}>
|
||||
<option value="low">Lower</option>
|
||||
<option value="mid">Mid</option>
|
||||
<option value="high">Higher</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90" type="submit">
|
||||
Get recommendations
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!!result && (
|
||||
<div className="space-y-6">
|
||||
<ResultCard
|
||||
title="Top recommendation"
|
||||
type={result.top[0]}
|
||||
why={result.why[result.top[0]]}
|
||||
/>
|
||||
|
||||
{result.top[1] && (
|
||||
<ResultCard
|
||||
title="Alternative to consider"
|
||||
type={result.top[1]}
|
||||
why={result.why[result.top[1]]}
|
||||
secondary
|
||||
/>
|
||||
)}
|
||||
|
||||
<CompareMatrix />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded hover:bg-muted" onClick={() => setResult(null)}>
|
||||
Start over
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 border rounded hover:bg-muted"
|
||||
onClick={() => { reset(); setResult(null); }}
|
||||
>
|
||||
New answers
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({
|
||||
title,
|
||||
type,
|
||||
why,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
type: LaserType;
|
||||
why?: string[];
|
||||
secondary?: boolean;
|
||||
}) {
|
||||
const info = TYPE_INFO[type];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{!secondary && <span className="text-xs rounded bg-muted px-2 py-0.5">Best match</span>}
|
||||
</div>
|
||||
<div className="text-base font-medium">{LASER_LABEL[type]}</div>
|
||||
<p className="text-sm text-muted-foreground">{info.summary}</p>
|
||||
|
||||
{!!why?.length && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Why this fits</div>
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
{why.slice(0, 5).map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 text-sm">
|
||||
<TagList label="Best for" items={info.bestFor} />
|
||||
<TagList label="Materials" items={info.materials} />
|
||||
<TagList label="Cautions" items={info.cautions} tone="warn" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Link className="text-sm underline hover:no-underline" href={info.learnLink}>
|
||||
See community settings
|
||||
</Link>
|
||||
<Link className="text-sm underline hover:no-underline" href="/submit/settings?target=settings_fiber">
|
||||
Suggest new settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({
|
||||
label,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
tone?: "warn";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">{label}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-xs px-2 py-1 rounded border ${
|
||||
tone === "warn" ? "bg-amber-50 border-amber-200" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareMatrix() {
|
||||
const rows: Array<{
|
||||
k: LaserType;
|
||||
label: string;
|
||||
best: string[];
|
||||
ok: string[];
|
||||
avoid: string[];
|
||||
}> = [
|
||||
{
|
||||
k: "fiber",
|
||||
label: LASER_LABEL.fiber,
|
||||
best: ["Bare metals", "Deep metal engrave", "Color marking stainless"],
|
||||
ok: ["Some coated items", "Some plastics w/ additives"],
|
||||
avoid: ["Thick organics cutting"],
|
||||
},
|
||||
{
|
||||
k: "co2_gantry",
|
||||
label: LASER_LABEL.co2_gantry,
|
||||
best: ["Acrylic cutting", "Wood cutting/engraving", "Large panels"],
|
||||
ok: ["Leathers, textiles, rubber"],
|
||||
avoid: ["Bare metals (no coat)"],
|
||||
},
|
||||
{
|
||||
k: "co2_galvo",
|
||||
label: LASER_LABEL.co2_galvo,
|
||||
best: ["Fast marking organics", "Photo engraving organics"],
|
||||
ok: ["Coated metals/non-metals"],
|
||||
avoid: ["Thick sheet cutting", "Large panels"],
|
||||
},
|
||||
{
|
||||
k: "uv",
|
||||
label: LASER_LABEL.uv,
|
||||
best: ["Micro features", "Glass/ceramic/plastics marking"],
|
||||
ok: ["Fine logos on coated metals"],
|
||||
avoid: ["Thick cutting"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4">
|
||||
<div className="text-sm font-medium mb-2">Compare laser types</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Best for</th>
|
||||
<th className="py-2 pr-3">Okay for</th>
|
||||
<th className="py-2">Not ideal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className="border-b last:border-0 align-top">
|
||||
<td className="py-2 pr-3 font-medium">{r.label}</td>
|
||||
<td className="py-2 pr-3">{r.best.join(", ")}</td>
|
||||
<td className="py-2 pr-3">{r.ok.join(", ")}</td>
|
||||
<td className="py-2">{r.avoid.join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
components/buying-guide/.bak/layout.tsx
Normal file
4
components/buying-guide/.bak/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
342
components/buying-guide/.bak/page.tsx
Normal file
342
components/buying-guide/.bak/page.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"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;
|
||||
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]);
|
||||
|
||||
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={`/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>
|
||||
)}
|
||||
<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 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={`/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>
|
||||
);
|
||||
}
|
||||
|
||||
188
components/buying-guide/.bak/product/[id]/page.tsx
Normal file
188
components/buying-guide/.bak/product/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// app/buying-guide/product/[id]/page.tsx
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
|
||||
|
||||
async function getEntry(id: string) {
|
||||
const res = await fetch(
|
||||
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
|
||||
{
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
console.error(`Failed to fetch entry: ${error}`);
|
||||
throw new Error(`Error fetching entry ${id}`);
|
||||
}
|
||||
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function ProductDetail({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
const entry = await getEntry(id);
|
||||
|
||||
const avgScore =
|
||||
entry?.scores?.length > 0
|
||||
? (
|
||||
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
|
||||
entry.scores.length
|
||||
).toFixed(1)
|
||||
: "N/A";
|
||||
|
||||
const headerUrl = entry.header?.id
|
||||
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header Banner */}
|
||||
{headerUrl && (
|
||||
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
|
||||
<img
|
||||
src={headerUrl}
|
||||
alt="Header Image"
|
||||
className="object-cover w-full h-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
|
||||
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entry.product_price && (
|
||||
<p className="text-lg text-white font-medium mt-1">
|
||||
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/buying-guide"
|
||||
className="text-sm text-blue-500 underline mt-2 inline-block"
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links & Score Summary */}
|
||||
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{Array.isArray(entry.links) && entry.links.length > 0 && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Links</h3>
|
||||
<ul className="list-disc ml-6 space-y-1">
|
||||
{entry.links.map((link: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 underline"
|
||||
>
|
||||
{link.text || link.url}
|
||||
</a>
|
||||
{link.target && (
|
||||
<span className="text-sm text-gray-500"> ({link.target})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
|
||||
<ul className="space-y-1">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<li key={idx} className="flex justify-between">
|
||||
<span>{s.cat}</span>
|
||||
<span className="font-semibold">{s.value}/10</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{entry.review_overview_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Overview</h3>
|
||||
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{entry.review_intro_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
|
||||
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Scores */}
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<div key={idx} className="p-4 rounded border">
|
||||
<p className="text-xl font-semibold">
|
||||
{s.cat} – <span className="text-blue-600">{s.value}/10</span>
|
||||
</p>
|
||||
<div className="text-sm text-gray-400">
|
||||
<ReactMarkdown>{s.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendation */}
|
||||
{entry.rec_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
|
||||
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates */}
|
||||
{entry.updates && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Updates</h3>
|
||||
<ReactMarkdown>{entry.updates}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{entry.video_review_url && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<iframe
|
||||
src={entry.video_review_url.replace("watch?v=", "embed/")}
|
||||
className="w-full h-96 rounded"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
// app/buying-guide/product/[id]/page.tsx
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
|
||||
|
||||
async function getEntry(id: string) {
|
||||
const res = await fetch(
|
||||
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
|
||||
{
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
console.error(`Failed to fetch entry: ${error}`);
|
||||
throw new Error(`Error fetching entry ${id}`);
|
||||
}
|
||||
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function ProductDetail({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = params.id;
|
||||
const entry = await getEntry(id);
|
||||
|
||||
const avgScore =
|
||||
entry?.scores?.length > 0
|
||||
? (
|
||||
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
|
||||
entry.scores.length
|
||||
).toFixed(1)
|
||||
: "N/A";
|
||||
|
||||
const headerUrl = entry.header?.id
|
||||
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header Banner */}
|
||||
{headerUrl && (
|
||||
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
|
||||
<img
|
||||
src={headerUrl}
|
||||
alt="Header Image"
|
||||
className="object-cover w-full h-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
|
||||
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entry.product_price && (
|
||||
<p className="text-lg text-white font-medium mt-1">
|
||||
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/buying-guide"
|
||||
className="text-sm text-blue-500 underline mt-2 inline-block"
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links & Score Summary */}
|
||||
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{Array.isArray(entry.links) && entry.links.length > 0 && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Links</h3>
|
||||
<ul className="list-disc ml-6 space-y-1">
|
||||
{entry.links.map((link: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 underline"
|
||||
>
|
||||
{link.text || link.url}
|
||||
</a>
|
||||
{link.target && (
|
||||
<span className="text-sm text-gray-500"> ({link.target})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
|
||||
<ul className="space-y-1">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<li key={idx} className="flex justify-between">
|
||||
<span>{s.cat}</span>
|
||||
<span className="font-semibold">{s.value}/10</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{entry.review_overview_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Overview</h3>
|
||||
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{entry.review_intro_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
|
||||
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Scores */}
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<div key={idx} className="p-4 rounded border">
|
||||
<p className="text-xl font-semibold">
|
||||
{s.cat} – <span className="text-blue-600">{s.value}/10</span>
|
||||
</p>
|
||||
<div className="text-sm text-gray-400">
|
||||
<ReactMarkdown>{s.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendation */}
|
||||
{entry.rec_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
|
||||
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates */}
|
||||
{entry.updates && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Updates</h3>
|
||||
<ReactMarkdown>{entry.updates}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{entry.video_review_url && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<iframe
|
||||
src={entry.video_review_url.replace("watch?v=", "embed/")}
|
||||
className="w-full h-96 rounded"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
190
components/buying-guide/BuyingGuideList.tsx
Normal file
190
components/buying-guide/BuyingGuideList.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
96
components/buying-guide/BuyingGuideProduct.tsx
Normal file
96
components/buying-guide/BuyingGuideProduct.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { dxGet } from "./dx";
|
||||
|
||||
type EntryDetail = {
|
||||
submission_id: string | number;
|
||||
title?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
body?: string | null;
|
||||
specs?: any;
|
||||
gallery?: { directus_files_id: { id: string; filename_disk?: string } }[];
|
||||
thumb?: { id: string };
|
||||
};
|
||||
|
||||
function assetUrl(id?: string) {
|
||||
if (!id) return "";
|
||||
return `/api/dx/assets/${id}`;
|
||||
}
|
||||
|
||||
export default function BuyingGuideProduct({ id }: { id: string | number }) {
|
||||
const [rec, setRec] = useState<EntryDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await dxGet<EntryDetail[]>("items/bg_entries", {
|
||||
filter: JSON.stringify({ submission_id: { _eq: id } }),
|
||||
fields: [
|
||||
"submission_id",
|
||||
"title",
|
||||
"brand",
|
||||
"model",
|
||||
"body",
|
||||
"specs",
|
||||
"thumb.id",
|
||||
"gallery.directus_files_id.id",
|
||||
].join(","),
|
||||
limit: 1,
|
||||
});
|
||||
setRec(data?.[0] || null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="text-sm text-zinc-400">Loading…</div>;
|
||||
if (!rec) return <div className="text-sm text-zinc-400">Not found.</div>;
|
||||
|
||||
const title = rec.model || rec.title || "Product";
|
||||
const images = [
|
||||
rec.thumb?.id,
|
||||
...(rec.gallery?.map(g => g.directus_files_id?.id).filter(Boolean) as string[]),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{rec.brand ? `${rec.brand} ` : ""}{title}
|
||||
</h2>
|
||||
<a
|
||||
href={`/buying-guide/${rec.submission_id}`}
|
||||
className="text-sm underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open full page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{images.map((id) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={id} src={assetUrl(id)} alt="" className="rounded-md border object-cover w-full aspect-[4/3]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rec.body && (
|
||||
<div className="prose prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: rec.body }} />
|
||||
)}
|
||||
|
||||
{rec.specs && (
|
||||
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto text-xs">
|
||||
{JSON.stringify(rec.specs, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
components/buying-guide/LaserFinderPanel.tsx
Normal file
171
components/buying-guide/LaserFinderPanel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { dxGet } from "./dx";
|
||||
|
||||
type Entry = {
|
||||
submission_id: string | number;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
title?: string | null;
|
||||
price_min?: number | null;
|
||||
price_max?: number | null;
|
||||
laser_type?: string | null; // e.g., "CO2", "Diode", "Fiber" if present in your schema
|
||||
bed_size_x?: number | null;
|
||||
bed_size_y?: number | null;
|
||||
};
|
||||
|
||||
export default function LaserFinderPanel() {
|
||||
const [q, setQ] = useState("");
|
||||
const [type, setType] = useState<string | "any">("any");
|
||||
const [budget, setBudget] = useState<number | null>(null);
|
||||
const [minBedX, setMinBedX] = useState<number | null>(null);
|
||||
const [minBedY, setMinBedY] = useState<number | null>(null);
|
||||
const [recs, setRecs] = useState<Entry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const f: any = {};
|
||||
const ors: any[] = [];
|
||||
if (q.trim()) {
|
||||
ors.push({ brand: { _icontains: q } }, { model: { _icontains: q } }, { title: { _icontains: q } });
|
||||
}
|
||||
if (type !== "any") f.laser_type = { _eq: type };
|
||||
if (budget != null) {
|
||||
// either min/max under budget or range overlaps
|
||||
ors.push(
|
||||
{ price_min: { _lte: budget } },
|
||||
{ price_max: { _lte: budget } },
|
||||
{ _and: [{ price_min: { _lte: budget } }, { price_max: { _gte: 1 } }] }
|
||||
);
|
||||
}
|
||||
if (minBedX != null) f.bed_size_x = { _gte: minBedX };
|
||||
if (minBedY != null) f.bed_size_y = { _gte: minBedY };
|
||||
if (ors.length) f._or = ors;
|
||||
return f;
|
||||
}, [q, type, budget, minBedX, minBedY]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await dxGet<Entry[]>("items/bg_entries", {
|
||||
fields: "submission_id,brand,model,title,price_min,price_max,laser_type,bed_size_x,bed_size_y",
|
||||
filter: JSON.stringify(filter),
|
||||
limit: 50,
|
||||
sort: "price_min,brand,model",
|
||||
});
|
||||
setRecs(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<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 className="flex flex-col">
|
||||
<label className="text-xs text-zinc-400 mb-1">Type</label>
|
||||
<select
|
||||
className="rounded-md border bg-background px-2 py-1"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as any)}
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
<option value="CO2">CO2</option>
|
||||
<option value="Diode">Diode</option>
|
||||
<option value="Fiber">Fiber</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-[140px]">
|
||||
<label className="text-xs text-zinc-400 mb-1">Budget (USD)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded-md border bg-background px-2 py-1"
|
||||
value={budget ?? ""}
|
||||
onChange={(e) => setBudget(e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-[120px]">
|
||||
<label className="text-xs text-zinc-400 mb-1">Min Bed X (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded-md border bg-background px-2 py-1"
|
||||
value={minBedX ?? ""}
|
||||
onChange={(e) => setMinBedX(e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-[120px]">
|
||||
<label className="text-xs text-zinc-400 mb-1">Min Bed Y (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded-md border bg-background px-2 py-1"
|
||||
value={minBedY ?? ""}
|
||||
onChange={(e) => setMinBedY(e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Searching…</div>
|
||||
) : recs.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">No matching lasers.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-zinc-400">
|
||||
<tr>
|
||||
<th className="py-1 pr-3 font-normal">Laser</th>
|
||||
<th className="py-1 pr-3 font-normal">Type</th>
|
||||
<th className="py-1 pr-3 font-normal">Bed (mm)</th>
|
||||
<th className="py-1 pr-3 font-normal">Price</th>
|
||||
<th className="py-1 pr-3 font-normal"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recs.map(r => (
|
||||
<tr key={r.submission_id} className="border-t">
|
||||
<td className="py-1 pr-3">{r.brand} {r.model || r.title}</td>
|
||||
<td className="py-1 pr-3">{r.laser_type || "—"}</td>
|
||||
<td className="py-1 pr-3">
|
||||
{(r.bed_size_x ?? "—")} × {(r.bed_size_y ?? "—")}
|
||||
</td>
|
||||
<td className="py-1 pr-3">
|
||||
{(r.price_min || r.price_max)
|
||||
? (r.price_min && r.price_max && r.price_min !== r.price_max
|
||||
? `$${r.price_min.toLocaleString()}–$${r.price_max.toLocaleString()}`
|
||||
: `$${(r.price_min || r.price_max)!.toLocaleString()}`)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-1 pr-3">
|
||||
<a
|
||||
className="underline"
|
||||
href={`/buying-guide/${r.submission_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
components/buying-guide/dx.ts
Normal file
12
components/buying-guide/dx.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// components/utilities/buying-guide/dx.ts
|
||||
export type Q = Record<string, any>;
|
||||
|
||||
export async function dxGet<T>(path: string, query?: Q): Promise<T> {
|
||||
const qs = query ? "?" + new URLSearchParams(Object.entries(query).flatMap(([k, v]) =>
|
||||
Array.isArray(v) ? v.map(x => [k, String(x)]) : [[k, String(v)]]
|
||||
)).toString() : "";
|
||||
const res = await fetch(`/api/dx/${path}${qs}`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
return json?.data ?? json;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue