Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Product } from "../types.js";
|
||||
import { Icon, Field, inputStyle } from "./primitives/index.js";
|
||||
|
||||
// Scan-friendly product picker. Auto-focuses on mount so a barcode scanner
|
||||
// can fire immediately. Exact (case-insensitive) match against sku or
|
||||
// assetTag → calls onMatch and clears itself for the next scan. Falls back
|
||||
// to a "no match" hint when the value doesn't resolve.
|
||||
export function ScanField({
|
||||
products,
|
||||
onMatch,
|
||||
matchedProduct,
|
||||
}: {
|
||||
products: Product[];
|
||||
onMatch: (productId: string) => void;
|
||||
matchedProduct: Product | null;
|
||||
}) {
|
||||
const [scan, setScan] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = scan.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
setFeedback(null);
|
||||
return;
|
||||
}
|
||||
const match = products.find(
|
||||
(p) =>
|
||||
p.sku.toLowerCase() === trimmed ||
|
||||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
|
||||
);
|
||||
if (match) {
|
||||
onMatch(match.id);
|
||||
setScan("");
|
||||
setFeedback({ type: "matched", text: `Matched ${match.name}` });
|
||||
}
|
||||
}, [scan]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Show "no match" only after the user has stopped typing for a beat —
|
||||
// avoids flashing "no match" mid-type while a multi-char SKU is entered.
|
||||
useEffect(() => {
|
||||
if (!scan.trim() || feedback?.type === "matched") return;
|
||||
const timer = setTimeout(() => {
|
||||
const trimmed = scan.trim().toLowerCase();
|
||||
const match = products.find(
|
||||
(p) =>
|
||||
p.sku.toLowerCase() === trimmed ||
|
||||
(p.assetTag != null && p.assetTag.toLowerCase() === trimmed),
|
||||
);
|
||||
if (!match) setFeedback({ type: "miss", text: "No SKU or asset tag matches that." });
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scan, products]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Field label="Scan SKU or asset tag" hint="Or pick from the list below.">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
...inputStyle,
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
<Icon name="search" size={14} color="var(--ink-3)" />
|
||||
<input
|
||||
autoFocus
|
||||
value={scan}
|
||||
onChange={(e) => setScan(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
placeholder="SKU-XXXXXX or AT-0000"
|
||||
style={{
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
padding: "10px 0",
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
color: "var(--ink)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
/>
|
||||
{matchedProduct && !scan && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--sage)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
✓ {matchedProduct.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{feedback && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: feedback.type === "matched" ? "var(--sage)" : "var(--terracotta)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user