Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
+110
View File
@@ -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>
);
}