small changes to laser-finder theming for better contrast

This commit is contained in:
makearmy 2025-09-22 19:13:27 -04:00
parent 62114470de
commit 0c0951848a

View file

@ -1,321 +1,479 @@
// 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";
import React, { useMemo, useState } from "react";
export default function LaserFinderPage() {
const [result, setResult] = useState<{
top: LaserType[];
score: Record<LaserType, number>;
why: Record<LaserType, string[]>;
} | null>(null);
/**
* Buying Guide Laser Finder
* Path: /buying-guide/finder
*
* - Lightweight scoring based on answers
* - Nice, readable "Cautions" chips
* - Clear callouts and reset / refine controls
* - Pure Tailwind, no external deps
*/
const { register, handleSubmit, reset, watch } = useForm<Answers>({
defaultValues: {
materials: [],
operations: [],
part_size: "medium",
detail: "medium",
throughput: "medium",
budget: "mid",
},
});
type LaserKind = "fiber" | "co2-galvo" | "co2-gantry" | "uv";
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
type Answers = {
material?: "metal" | "organic" | "mixed";
task?: "mark" | "engrave" | "cut";
detail?: "fine" | "medium" | "coarse";
size?: "small" | "medium" | "large";
colorChangeOk?: boolean; // okay with color/anneal vs. deep mark
budget?: "tight" | "moderate" | "flexible";
};
type Profile = {
id: LaserKind;
title: string;
wavelength: string;
bestFor: string[];
cautions: string[];
notes?: string;
learnLink?: string; // internal doc/page if you add one later
};
const PROFILES: Record<LaserKind, Profile> = {
fiber: {
id: "fiber",
title: "Fiber (1064 nm • Galvo)",
wavelength: "1064 nm",
bestFor: [
"Marking/engraving bare metals",
"High-detail logos & serials",
"Fast batch work on small parts",
],
cautions: [
"Small scan field vs gantry machines",
"Not for cutting non-metals",
"Ventilation needed for some plastics",
],
notes:
"Ideal for stainless, aluminum, tool steels. Deep marks possible with multiple passes.",
},
"co2-galvo": {
id: "co2-galvo",
title: "CO₂ Galvo (10.6 µm)",
wavelength: "10.6 µm",
bestFor: [
"Rapid engraving on wood, leather, glass, acrylic",
"Small items in trays/fixtures",
"Branding & raster graphics",
],
cautions: [
"Poor on bare metals (needs marking spray)",
"Smaller working area than gantry CO₂",
"Not ideal for thick cutting",
],
notes:
"When you need speed on organic materials at smaller sizes, CO₂ galvo is excellent.",
},
"co2-gantry": {
id: "co2-gantry",
title: "CO₂ Gantry (10.6 µm)",
wavelength: "10.6 µm",
bestFor: [
"Cutting acrylic & organics",
"Larger panels/signage",
"Prototyping jigs and fixtures",
],
cautions: [
"Not for bare metals without compound",
"Finer micro-detail is slower than galvos",
],
notes:
"Your general-purpose cutter/engraver for wood, acrylic, cardboard, leather, and more.",
},
uv: {
id: "uv",
title: "UV (355 nm • Galvo)",
wavelength: "355 nm",
bestFor: [
"Heat-sensitive plastics & films",
"Glass, ceramics, and clear/translucent parts",
"High-contrast codes on consumer packaging",
],
cautions: [
"Higher cost per watt than fiber/CO₂",
"Slower for deep material removal",
"Keep optics clean; sensitive to contamination",
],
notes:
"When you need crisp marks with minimal heat on delicate or clear materials, UV is the tool.",
},
};
/**
* Scoring rules
* - Very simple heuristic = good enough for a first pass.
* - Feel free to tweak weights as you gather user feedback.
* */
function scoreProfiles(a: Answers): Array<{ id: LaserKind; score: number; why: string[] }> {
const why: Record<LaserKind, string[]> = {
fiber: [],
"co2-galvo": [],
"co2-gantry": [],
uv: [],
};
const s: Record<LaserKind, number> = { fiber: 0, "co2-galvo": 0, "co2-gantry": 0, uv: 0 };
const selectedMaterials = watch("materials");
const selectedOps = watch("operations");
// Material
if (a.material === "metal") {
s.fiber += 6; why.fiber.push("Best for bare metals");
s.uv += 1; why.uv.push("Can mark some coated metals");
}
if (a.material === "organic") {
s["co2-gantry"] += 4; why["co2-gantry"].push("Great on wood & acrylic");
s["co2-galvo"] += 4; why["co2-galvo"].push("Super fast on organics");
}
if (a.material === "mixed") {
s.fiber += 2; why.fiber.push("Covers metals");
s["co2-gantry"] += 2; why["co2-gantry"].push("Cuts & engraves organics");
s["co2-galvo"] += 2; why["co2-galvo"].push("Fast organic engraving");
s.uv += 1; why.uv.push("Excellent for delicate/clear materials");
}
// Task
if (a.task === "mark" || a.task === "engrave") {
s.fiber += 3; why.fiber.push("High-contrast metal marking");
s["co2-galvo"] += 2; why["co2-galvo"].push("Fast raster engraving");
s.uv += 2; why.uv.push("Clean marks on delicate substrates");
}
if (a.task === "cut") {
if (a.material !== "metal") {
s["co2-gantry"] += 6; why["co2-gantry"].push("Best for cutting non-metals");
}
}
// Detail
if (a.detail === "fine") {
s.fiber += 2; why.fiber.push("Very fine vector detail");
s.uv += 2; why.uv.push("Minimal heat, crisp small features");
s["co2-galvo"] += 1; why["co2-galvo"].push("Fast raster detail (smaller fields)");
}
if (a.detail === "coarse") {
s["co2-gantry"] += 1; why["co2-gantry"].push("Large letters & cuts ok");
}
// Part size
if (a.size === "large") {
s["co2-gantry"] += 3; why["co2-gantry"].push("Large work area");
}
if (a.size === "small") {
s.fiber += 1; why.fiber.push("Small parts throughput");
s["co2-galvo"] += 1; why["co2-galvo"].push("Tray/fixture friendly");
}
// Color change acceptable?
if (a.colorChangeOk === true) {
s.fiber += 1; why.fiber.push("Black/anneal OK");
s.uv += 1; why.uv.push("High contrast on plastics/glass");
}
// Budget (soft nudge)
if (a.budget === "tight") {
s["co2-gantry"] += 1; why["co2-gantry"].push("Often best $/area for organics");
}
if (a.budget === "flexible") {
s.uv += 1; why.uv.push("Premium for delicate materials");
}
return (Object.keys(s) as LaserKind[])
.map((id) => ({ id, score: s[id], why: why[id] }))
.sort((a, b) => b.score - a.score);
}
/**
* UI Bits
* */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
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 well suggest the best laser <em>types</em> for your work
with clear use-cases, materials, and cautions. No product pitchesjust guidance.
</p>
<section className="rounded-2xl border border-border/60 bg-card p-4 md:p-6">
<h2 className="mb-3 text-lg font-semibold">{title}</h2>
{children}
</section>
);
}
{!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>
function Seg({
value,
onChange,
items,
}: {
value?: string;
onChange: (v: string) => void;
items: { value: string; label: string }[];
}) {
return (
<div className="flex flex-wrap gap-2">
{items.map((it) => {
const active = value === it.value;
return (
<button
key={it.value}
type="button"
onClick={() => onChange(it.value)}
className={
"rounded-full px-3 py-1.5 text-sm transition " +
(active
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 hover:bg-muted text-foreground")
}
>
{it.label}
</button>
);
})}
</div>
);
}
{/* 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>
function YesNo({ value, onChange }: { value?: boolean; onChange: (v: boolean) => void }) {
return (
<Seg
value={value === undefined ? undefined : value ? "yes" : "no"}
onChange={(v) => onChange(v === "yes")}
items={[
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
]}
/>
);
}
{/* 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>
function Pill({ children }: { children: React.ReactNode }) {
return (
<li className="rounded-full border border-foreground/10 bg-foreground/5 px-3 py-1 text-sm leading-6">
{children}
</li>
);
}
<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); }}
/** High-contrast, accessible caution chips */
function Cautions({ items }: { items: string[] }) {
return (
<div className="rounded-xl border border-amber-500/40 bg-amber-50/70 p-4 dark:border-amber-400/30 dark:bg-amber-900/25">
<div className="mb-2 flex items-center gap-2">
<span aria-hidden></span>
<h4 className="font-semibold text-amber-900 dark:text-amber-200">Cautions</h4>
</div>
<ul className="flex flex-wrap gap-2">
{items.map((c, i) => (
<li
key={i}
className="rounded-full border border-amber-700/20 bg-amber-100 px-3 py-1 text-sm text-amber-900 shadow-sm dark:border-amber-300/20 dark:bg-amber-800/60 dark:text-amber-50"
>
New answers
</button>
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
Back to Buying Guide
</Link>
</div>
</div>
)}
{c}
</li>
))}
</ul>
</div>
);
}
function ResultCard({
title,
type,
pick,
why,
secondary,
rank,
}: {
title: string;
type: LaserType;
why?: string[];
secondary?: boolean;
pick: Profile;
why: string[];
rank: number;
}) {
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
className={
"rounded-2xl border p-5 transition " +
(rank === 0
? "border-primary/50 bg-primary/5 ring-1 ring-primary/20"
: "border-border bg-card")
}
>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-base font-semibold">
{rank === 0 ? "Recommended" : "Alternative"} · {pick.title}
</h3>
<span className="text-xs text-muted-foreground">{pick.wavelength}</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>
{pick.notes && <p className="mb-3 text-sm text-muted-foreground">{pick.notes}</p>}
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">Best For</h4>
<ul className="flex flex-wrap gap-2">
{pick.bestFor.map((b, i) => (
<Pill key={i}>{b}</Pill>
))}
</ul>
</div>
<Cautions items={pick.cautions} />
{why.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground">
<span className="font-medium">Why youre seeing this:</span>{" "}
{why.join(" · ")}
</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>
);
}
/**
* Page
* */
export default function FinderPage() {
const [a, setA] = useState<Answers>({});
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"],
},
];
const results = useMemo(() => scoreProfiles(a), [a]);
const complete =
a.material && a.task && a.detail && a.size && a.colorChangeOk !== undefined;
const top = results[0];
const alt = results.slice(1, 3);
function reset() {
setA({});
window.scrollTo({ top: 0, behavior: "smooth" });
}
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 className="mx-auto max-w-5xl px-4 py-8 md:py-10">
<header className="mb-6">
<h1 className="text-2xl font-bold">Laser Finder</h1>
<p className="mt-1 text-sm text-muted-foreground">
Answer a few quick questions and well suggest the laser family that
fits your work best.
</p>
</header>
<div className="grid gap-4 md:grid-cols-2">
<Section title="Material">
<Seg
value={a.material}
onChange={(v) => setA((p) => ({ ...p, material: v as Answers["material"] }))}
items={[
{ value: "metal", label: "Metals" },
{ value: "organic", label: "Organics / Acrylic" },
{ value: "mixed", label: "Mixed" },
]}
/>
</Section>
<Section title="Primary Task">
<Seg
value={a.task}
onChange={(v) => setA((p) => ({ ...p, task: v as Answers["task"] }))}
items={[
{ value: "mark", label: "Mark" },
{ value: "engrave", label: "Engrave" },
{ value: "cut", label: "Cut" },
]}
/>
</Section>
<Section title="Detail Level">
<Seg
value={a.detail}
onChange={(v) => setA((p) => ({ ...p, detail: v as Answers["detail"] }))}
items={[
{ value: "fine", label: "Fine" },
{ value: "medium", label: "Medium" },
{ value: "coarse", label: "Coarse" },
]}
/>
</Section>
<Section title="Part Size">
<Seg
value={a.size}
onChange={(v) => setA((p) => ({ ...p, size: v as Answers["size"] }))}
items={[
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
]}
/>
</Section>
<Section title="Is color/anneal OK (vs deep removal)?">
<YesNo
value={a.colorChangeOk}
onChange={(v) => setA((p) => ({ ...p, colorChangeOk: v }))}
/>
</Section>
<Section title="Budget (optional)">
<Seg
value={a.budget}
onChange={(v) => setA((p) => ({ ...p, budget: v as Answers["budget"] }))}
items={[
{ value: "tight", label: "Tight" },
{ value: "moderate", label: "Moderate" },
{ value: "flexible", label: "Flexible" },
]}
/>
</Section>
</div>
{/* Results */}
<div className="mt-8 space-y-4">
{!complete ? (
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
Fill out the questions above to see recommendations.
</div>
) : (
<>
{top && (
<ResultCard
pick={PROFILES[top.id]}
why={top.why}
rank={0}
/>
)}
{alt.map((r, i) => (
<ResultCard key={r.id} pick={PROFILES[r.id]} why={r.why} rank={i + 1} />
))}
</>
)}
</div>
{/* Actions */}
<div className="mt-8 flex flex-wrap gap-3">
<button
type="button"
onClick={reset}
className="rounded-lg border bg-background px-3 py-2 text-sm shadow-sm hover:bg-muted"
>
Start Over
</button>
{/* Quick links back into your existing app sections */}
<a
href="/fiber-settings"
className="rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground shadow-sm hover:opacity-90"
>
See Fiber Examples
</a>
<a
href="/co2-gantry-settings"
className="rounded-lg bg-primary/90 px-3 py-2 text-sm text-primary-foreground shadow-sm hover:opacity-90"
>
See CO Gantry
</a>
<a
href="/co2-galvo-settings"
className="rounded-lg bg-primary/90 px-3 py-2 text-sm text-primary-foreground shadow-sm hover:opacity-90"
>
See CO Galvo
</a>
<a
href="/uv-settings"
className="rounded-lg bg-primary/90 px-3 py-2 text-sm text-primary-foreground shadow-sm hover:opacity-90"
>
See UV
</a>
</div>
</div>
);