laser finder fix

This commit is contained in:
makearmy 2025-10-15 16:53:14 -04:00
parent 73bba240ab
commit ab93441fc5
2 changed files with 473 additions and 230 deletions

View file

@ -1,96 +1,188 @@
"use client";
// app/buying-guide/product/[id]/page.tsx
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import { useEffect, useState } from "react";
import { dxGet } from "./dx";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
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 };
};
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",
}
);
function assetUrl(id?: string) {
if (!id) return "";
return `/api/dx/assets/${id}`;
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 function BuyingGuideProduct({ id }: { id: string | number }) {
const [rec, setRec] = useState<EntryDetail | null>(null);
const [loading, setLoading] = useState(true);
export default async function ProductDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const entry = await getEntry(id);
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]);
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";
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);
const headerUrl = entry.header?.id
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
: null;
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"
<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"
>
Open full page
</a>
Back to Buying Guide
</Link>
</div>
</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]" />
{/* 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>
)}
{rec.body && (
<div className="prose prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: rec.body }} />
{/* 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>
)}
{rec.specs && (
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto text-xs">
{JSON.stringify(rec.specs, null, 2)}
</pre>
{/* 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>
);
}

View file

@ -1,171 +1,322 @@
// app/buying-guide/finder/page.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { dxGet } from "./dx";
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";
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 LaserFinderPage() {
const [result, setResult] = useState<{
top: LaserType[];
score: Record<LaserType, number>;
why: Record<LaserType, string[]>;
} | null>(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 { register, handleSubmit, reset, watch } = useForm<Answers>({
defaultValues: {
materials: [],
operations: [],
part_size: "medium",
detail: "medium",
throughput: "medium",
budget: "mid",
},
});
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]);
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
};
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]);
const selectedMaterials = watch("materials");
const selectedOps = watch("operations");
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="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 well suggest the best laser <em>types</em> for your work
with clear use-cases, materials, and cautions. No product pitchesjust guidance.
</p>
<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>
{!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>
))}
</tbody>
</table>
</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 (&gt; 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>
);
}