reverting to original finder

This commit is contained in:
makearmy 2025-09-22 19:24:02 -04:00
parent 98a7d7422e
commit 0ba2f9622e

View file

@ -1,536 +1,322 @@
'use client';
// app/buying-guide/finder/page.tsx
"use client";
import { useMemo, useState } from 'react';
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";
/* -----------------------------------------------------------
* Data: laser types (copy tweaks welcome)
* ----------------------------------------------------------- */
type LaserKey = 'co2_gantry' | 'co2_galvo' | 'fiber' | 'uv';
export default function LaserFinderPage() {
const [result, setResult] = useState<{
top: LaserType[];
score: Record<LaserType, number>;
why: Record<LaserType, string[]>;
} | null>(null);
const LASER_TYPES: Record<LaserKey, {
title: string;
blurb: string;
cautions: string[];
strengths: string[];
typical: string[];
}> = {
co2_gantry: {
title: 'CO₂ Gantry',
blurb:
'Large work areas, great for cutting/engraving organics (wood, leather, paper) and many plastics. Best for sheet work and signage.',
cautions: [
'Requires robust ventilation & fire safety for cutting organics.',
'Not suitable for bare metals without coatings/pastes.',
],
strengths: [
'Big beds (e.g. 600×400mm+)',
'Cuts thicker organics cleanly',
'Lower cost per watt than galvos',
],
typical: ['signs', 'boxes', 'inlays', 'production sheet work'],
},
co2_galvo: {
title: 'CO₂ Galvo',
blurb:
'High-speed galvo for organics/plastics in smaller fields (e.g. 110×110mm). Excellent for rapid engraving and serialization.',
cautions: [
'Smaller field; “stitching” required for large designs.',
'Requires smoke extraction for organics/plastics.',
],
strengths: [
'Very fast engraving',
'Sharp detail in small fields',
'Great for coated/anodized items',
],
typical: ['batch marking', 'brand marks', 'rapid engraving'],
},
fiber: {
title: 'Fiber (1064nm)',
blurb:
'The go-to for metals. Deep engraving, anneal/black marking, and serial/UID marking. Small F-theta fields; superb precision.',
cautions: [
'Not suited for clear/white acrylic; limited plastics.',
'Eye safety is critical; use certified eyewear/enclosure.',
],
strengths: [
'Marks/engraves steel, aluminum, brass, Ti, etc.',
'High precision in small fields',
'Supports color marking on some alloys (with tuning)',
],
typical: ['metal tags', 'tools', 'knives', 'jewelry', 'UIDs'],
},
uv: {
title: 'UV (355nm)',
blurb:
'Best for fine marking of plastics, PCBs, some glass/ceramics with minimal heat. Micro features and delicate substrates.',
cautions: [
'Generally slower and pricier per watt.',
'UV eye/skin safety needs strict controls.',
],
strengths: [
'Minimal heat-affected zone',
'Excellent for delicate plastics & micro text',
'Can mark glass/ceramics with good contrast',
],
typical: ['medical devices', 'plastics, PCB legends', 'micro text'],
},
};
const { register, handleSubmit, reset, watch } = useForm<Answers>({
defaultValues: {
materials: [],
operations: [],
part_size: "medium",
detail: "medium",
throughput: "medium",
budget: "mid",
},
});
/* -----------------------------------------------------------
* Styling-only Cautions component (contrast fix)
* ----------------------------------------------------------- */
function CautionBox({ items }: { items?: string[] }) {
if (!items || items.length === 0) return null;
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
role="alert"
className="
mt-4 rounded-xl border border-amber-500/50
bg-amber-50 text-amber-950
ring-1 ring-amber-500/20 shadow-sm
dark:bg-amber-950/40 dark:text-amber-100
dark:border-amber-400/40 dark:ring-amber-400/20
"
>
<div className="flex items-start gap-3 p-3">
<span
aria-hidden
className="
inline-flex h-6 w-6 shrink-0 items-center justify-center
rounded-full bg-amber-500 text-white text-sm font-bold
"
>
!
</span>
<div className="min-w-0">
<div
className="
text-xs font-semibold uppercase tracking-wide
text-amber-800 dark:text-amber-200
"
>
Cautions
</div>
<ul className="mt-1 list-disc pl-5 space-y-1 marker:text-amber-700 dark:marker:text-amber-300">
{items.map((it, i) => (
<li key={i} className="leading-snug">
{it}
</li>
))}
</ul>
</div>
</div>
</div>
);
}
/* -----------------------------------------------------------
* Questionnaire model
* (Focused on use-cases; no product ids)
* ----------------------------------------------------------- */
type Answers = {
materials: 'organics' | 'metals' | 'both' | 'plastics_glass';
workSize: 'small' | 'medium' | 'large' | 'micro';
detail: 'low' | 'medium' | 'high' | 'micro';
throughput: 'normal' | 'high';
colorOnMetal: 'needed' | 'nice' | 'no';
budget: 'low' | 'mid' | 'high';
reflectivity: 'low' | 'mixed' | 'high';
enclosure: 'enclosed' | 'open_ok';
};
const DEFAULT_ANSWERS: Answers = {
materials: 'both',
workSize: 'medium',
detail: 'medium',
throughput: 'normal',
colorOnMetal: 'no',
budget: 'mid',
reflectivity: 'mixed',
enclosure: 'enclosed',
};
/* -----------------------------------------------------------
* Scoring rules (easy to tweak)
* ----------------------------------------------------------- */
type ScoreMap = Record<LaserKey, number>;
const BASE_WEIGHTS: ScoreMap = {
co2_gantry: 0,
co2_galvo: 0,
fiber: 0,
uv: 0,
};
function score(answers: Answers): ScoreMap {
const s: ScoreMap = { ...BASE_WEIGHTS };
// Materials
if (answers.materials === 'organics') {
s.co2_gantry += 4;
s.co2_galvo += 3;
} else if (answers.materials === 'metals') {
s.fiber += 5;
} else if (answers.materials === 'plastics_glass') {
s.uv += 5;
s.co2_galvo += 2;
} else if (answers.materials === 'both') {
s.co2_gantry += 2;
s.co2_galvo += 2;
s.fiber += 2;
}
// Work size
if (answers.workSize === 'large') s.co2_gantry += 5;
if (answers.workSize === 'medium') s.co2_gantry += 2;
if (answers.workSize === 'small') s.co2_galvo += 2;
if (answers.workSize === 'micro') {
s.fiber += 3;
s.uv += 3;
}
// Detail
if (answers.detail === 'low') s.co2_gantry += 1;
if (answers.detail === 'medium') {
s.co2_gantry += 1;
s.co2_galvo += 1;
}
if (answers.detail === 'high') {
s.co2_galvo += 2;
s.fiber += 2;
}
if (answers.detail === 'micro') {
s.fiber += 4;
s.uv += 4;
}
// Throughput
if (answers.throughput === 'high') {
s.co2_galvo += 3;
s.fiber += 3;
}
// Color on metal
if (answers.colorOnMetal === 'needed') s.fiber += 3;
if (answers.colorOnMetal === 'nice') s.fiber += 1;
// Budget
if (answers.budget === 'low') s.co2_gantry += 2;
if (answers.budget === 'mid') {
s.co2_gantry += 1;
s.co2_galvo += 1;
s.fiber += 1;
}
if (answers.budget === 'high') {
s.fiber += 1;
s.uv += 1;
s.co2_galvo += 1;
}
// Reflectivity exposure
if (answers.reflectivity === 'high') s.fiber += 1; // designed for metals
if (answers.reflectivity === 'low') s.co2_gantry += 1;
// Enclosure requirement
if (answers.enclosure === 'enclosed') {
s.co2_gantry += 1; // many enclosed units exist
s.fiber += 1; // many enclosed stations exist
s.uv += 1; // usually enclosed
} else {
s.co2_galvo += 1; // many open-table galvos
}
return s;
}
/* -----------------------------------------------------------
* Small UI helpers
* ----------------------------------------------------------- */
function Card({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`rounded-2xl border p-4 shadow-sm bg-white/80 dark:bg-zinc-900/60 ${className}`}
>
{children}
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
{children}
</h2>
);
}
/* -----------------------------------------------------------
* The Page
* ----------------------------------------------------------- */
export default function FinderPage() {
const [answers, setAnswers] = useState<Answers>({ ...DEFAULT_ANSWERS });
const [showResults, setShowResults] = useState(false);
const results = useMemo(() => {
const scores = score(answers);
return (Object.keys(scores) as LaserKey[])
.map((k) => ({ key: k, score: scores[k] }))
.sort((a, b) => b.score - a.score);
}, [answers]);
const top = results.slice(0, 2); // show top 12
return (
<div className="mx-auto max-w-5xl px-4 py-6 md:py-10">
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
Laser Type Finder
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Answer a few questions about your work. Well suggest the laser{' '}
<em>type</em> (CO gantry, CO galvo, Fiber, UV) that fits best. No
product adsjust use-case guidance.
<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>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<SectionTitle>What are you working with?</SectionTitle>
<div className="mt-3 grid gap-3">
<label className="grid gap-1">
<span className="text-sm">Primary materials</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.materials}
onChange={(e) =>
setAnswers((a) => ({
...a,
materials: e.target.value as Answers['materials'],
}))
}
>
<option value="organics">Organics (wood, leather, paper, acrylic)</option>
<option value="metals">Metals (steel, aluminum, brass, Ti)</option>
<option value="plastics_glass">Plastics/Glass/Ceramics/PCB</option>
<option value="both">A mix of the above</option>
</select>
</label>
{!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>
<label className="grid gap-1">
<span className="text-sm">Typical work size</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.workSize}
onChange={(e) =>
setAnswers((a) => ({
...a,
workSize: e.target.value as Answers['workSize'],
}))
}
>
<option value="small">Small parts ( 200×200mm)</option>
<option value="medium">Bench-top size ( 300×500mm)</option>
<option value="large">Large sheets/panels ( 600mm width)</option>
<option value="micro">Micro/very fine work</option>
</select>
</label>
{/* 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>
<label className="grid gap-1">
<span className="text-sm">Detail priority</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.detail}
onChange={(e) =>
setAnswers((a) => ({
...a,
detail: e.target.value as Answers['detail'],
}))
}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="micro">Micro text/very fine detail</option>
</select>
</label>
{/* 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>
<label className="grid gap-1">
<span className="text-sm">Throughput requirement</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.throughput}
onChange={(e) =>
setAnswers((a) => ({
...a,
throughput: e.target.value as Answers['throughput'],
}))
}
>
<option value="normal">Normal</option>
<option value="high">High speed / batch marking</option>
</select>
</label>
</div>
</Card>
<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>
)}
<Card>
<SectionTitle>Constraints & preferences</SectionTitle>
<div className="mt-3 grid gap-3">
<label className="grid gap-1">
<span className="text-sm">Color marking on metals</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.colorOnMetal}
onChange={(e) =>
setAnswers((a) => ({
...a,
colorOnMetal: e.target.value as Answers['colorOnMetal'],
}))
}
>
<option value="no">Not needed</option>
<option value="nice">Nice to have</option>
<option value="needed">Required</option>
</select>
</label>
{!!result && (
<div className="space-y-6">
<ResultCard
title="Top recommendation"
type={result.top[0]}
why={result.why[result.top[0]]}
/>
<label className="grid gap-1">
<span className="text-sm">Budget</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.budget}
onChange={(e) =>
setAnswers((a) => ({
...a,
budget: e.target.value as Answers['budget'],
}))
}
>
<option value="low">Entry / lower budget</option>
<option value="mid">Mid</option>
<option value="high">High / industrial</option>
</select>
</label>
{result.top[1] && (
<ResultCard
title="Alternative to consider"
type={result.top[1]}
why={result.why[result.top[1]]}
secondary
/>
)}
<label className="grid gap-1">
<span className="text-sm">High-reflectivity metals common?</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.reflectivity}
onChange={(e) =>
setAnswers((a) => ({
...a,
reflectivity: e.target.value as Answers['reflectivity'],
}))
}
>
<option value="low">Mostly non-metals</option>
<option value="mixed">Mixed</option>
<option value="high">Often very reflective metals</option>
</select>
</label>
<CompareMatrix />
<label className="grid gap-1">
<span className="text-sm">Enclosure requirement</span>
<select
className="rounded-md border px-2 py-1 bg-background"
value={answers.enclosure}
onChange={(e) =>
setAnswers((a) => ({
...a,
enclosure: e.target.value as Answers['enclosure'],
}))
}
>
<option value="enclosed">Must be enclosed</option>
<option value="open_ok">Open table is acceptable</option>
</select>
</label>
</div>
</Card>
</div>
<div className="mt-4 flex gap-2">
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
onClick={() => setShowResults(true)}
>
See Recommendation
</button>
<button
className="rounded-md border px-4 py-2 hover:bg-accent"
onClick={() => {
setAnswers({ ...DEFAULT_ANSWERS });
setShowResults(false);
}}
>
Reset
</button>
</div>
{showResults && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{top.map(({ key, score }) => {
const t = LASER_TYPES[key];
return (
<Card key={key}>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">{t.title}</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-300">
{t.blurb}
</p>
</div>
<span className="rounded-md bg-zinc-100 px-2 py-1 text-xs font-medium dark:bg-zinc-800">
Score {score}
</span>
</div>
<div className="mt-3 grid gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-wide">
Strengths
</div>
<ul className="mt-1 list-disc pl-5 space-y-1">
{t.strengths.map((s, i) => (
<li key={i} className="text-sm">
{s}
</li>
))}
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide">
Typical jobs
</div>
<ul className="mt-1 list-disc pl-5 space-y-1">
{t.typical.map((s, i) => (
<li key={i} className="text-sm">
{s}
</li>
))}
</ul>
</div>
</div>
{/* High-contrast cautions */}
<CautionBox items={t.cautions} />
</Card>
);
})}
<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>
);
}