Mobile view overhaul: bottom nav, card inventory, camera scanner, PWA
Build and push image / build (push) Successful in 1m25s

Replace the cramped horizontal sidebar with a 5-tab bottom nav (Home,
Inventory, Scan, Custody, More) with an elevated scan button. Convert
the 10-column inventory table to card-based list on mobile with
scrollable filter pills and long-press selection. Add full-screen
camera barcode scanner using html5-qrcode with post-scan action sheet.
Set up PWA with vite-plugin-pwa for add-to-home-screen. Convert modals
to bottom sheets, add pull-to-refresh, safe-area padding, and iOS
input zoom fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:23:43 -04:00
parent 52564d1e2f
commit 11f4c0537d
24 changed files with 6160 additions and 344 deletions
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
<meta name="theme-color" content="#f5efe6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Apothecary" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>Apothecary — Personal Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+4474 -89
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.62.7",
"html5-qrcode": "^2.3.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.14.2"
@@ -20,6 +21,7 @@
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vite-plugin-pwa": "^1.3.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="32" fill="#f5efe6"/>
<circle cx="96" cy="92" r="44" fill="none" stroke="#2d2a26" stroke-width="2"/>
<text x="96" y="105" text-anchor="middle" font-family="Georgia, serif" font-size="52" font-style="italic" fill="#2d2a26">A</text>
<text x="96" y="156" text-anchor="middle" font-family="Georgia, serif" font-size="14" font-weight="500" letter-spacing="2" fill="#8b8579">APOTHECARY</text>
</svg>

After

Width:  |  Height:  |  Size: 524 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="80" fill="#f5efe6"/>
<circle cx="256" cy="240" r="110" fill="none" stroke="#2d2a26" stroke-width="3"/>
<text x="256" y="275" text-anchor="middle" font-family="Georgia, serif" font-size="130" font-style="italic" fill="#2d2a26">A</text>
<text x="256" y="410" text-anchor="middle" font-family="Georgia, serif" font-size="36" font-weight="500" letter-spacing="5" fill="#8b8579">APOTHECARY</text>
</svg>

After

Width:  |  Height:  |  Size: 530 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#f5efe6"/>
<circle cx="256" cy="256" r="100" fill="none" stroke="#2d2a26" stroke-width="3"/>
<text x="256" y="290" text-anchor="middle" font-family="Georgia, serif" font-size="120" font-style="italic" fill="#2d2a26">A</text>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+81
View File
@@ -13,6 +13,13 @@ type DrawerBack =
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js";
import { MobileBottomNav } from "./components/MobileBottomNav.js";
import { CameraScanner } from "./components/CameraScanner.js";
import { ScanAction } from "./components/ScanAction.js";
import { lookup } from "./components/ScanField.js";
import type { ScanResult } from "./components/ScanField.js";
import { useIsMobile } from "./hooks/useIsMobile.js";
import { usePullToRefresh } from "./hooks/usePullToRefresh.js";
import { Dashboard } from "./views/Dashboard.js";
import { Inventory } from "./views/Inventory.js";
import { SkusView } from "./views/SkusView.js";
@@ -87,6 +94,15 @@ export function App() {
const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [drawerBack, setDrawerBack] = useState<DrawerBack>(null);
const isMobile = useIsMobile();
const [scannerOpen, setScannerOpen] = useState(false);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [scanNoMatch, setScanNoMatch] = useState<string | null>(null);
const { pulling, refreshing } = usePullToRefresh(
() => queryClient.invalidateQueries({ queryKey: ["bootstrap"] }),
);
const [theme, setTheme] = useState<ThemeKey>(
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
);
@@ -182,6 +198,19 @@ export function App() {
setModal("edit");
};
const handleScan = (text: string) => {
const hit = lookup(text.trim().toLowerCase(), items, data?.products);
if (hit) {
setScanResult(hit);
setScanNoMatch(null);
setScannerOpen(false);
} else {
setScanResult(null);
setScanNoMatch(text.trim());
setScannerOpen(false);
}
};
const openBulkEdit = (items: Item[]) => { setBulkItems(items); setModal("bulkEdit"); };
const openBulkConsume = (items: Item[]) => { setBulkItems(items); setModal("bulkConsume"); };
const openBulkCheckout = (items: Item[]) => { setBulkItems(items); setModal("bulkCheckout"); };
@@ -240,14 +269,29 @@ export function App() {
return (
<div className="app-shell" data-screen-label="App">
{!isMobile && (
<Sidebar
onAddProduct={openAdd}
onMarkFinished={() => openConsume()}
onAudit={() => openAudit()}
onCheckout={() => openCheckout()}
/>
)}
<main className="main parchment" style={{ minWidth: 0 }}>
{isMobile && (pulling || refreshing) && (
<div
style={{
display: "flex",
justifyContent: "center",
padding: "12px 0",
fontSize: 12,
color: "var(--ink-3)",
}}
>
{refreshing ? "Refreshing…" : "Pull to refresh"}
</div>
)}
<Routes>
<Route path="/" element={
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
@@ -460,6 +504,43 @@ export function App() {
{modal === "editSku" && modalProduct && (
<EditSkuModal data={data} product={modalProduct} onClose={() => setModal(null)} />
)}
{isMobile && (
<MobileBottomNav
onScan={() => setScannerOpen(true)}
onAddProduct={openAdd}
onMarkFinished={() => openConsume()}
onAudit={() => openAudit()}
onCheckout={() => openCheckout()}
/>
)}
{scannerOpen && (
<CameraScanner
onScan={handleScan}
onClose={() => setScannerOpen(false)}
/>
)}
{data && (scanResult || scanNoMatch) && (
<ScanAction
result={scanResult}
data={data}
noMatchText={scanNoMatch}
onClose={() => { setScanResult(null); setScanNoMatch(null); }}
onViewItem={(i) => setSelected(i)}
onAudit={(i) => openAudit(i)}
onCheckout={(i) => openCheckout(i)}
onCheckin={(i) => openCheckin(i)}
onConsume={(i) => openConsume(i)}
onMarkGone={(i) => openMarkGone(i)}
onAddInventory={openAdd}
onViewSku={(p) => setSelectedSku(p)}
onCreateProduct={(sku) => {
setModal("addSku");
}}
/>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
export function BottomSheet({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [closing, setClosing] = useState(false);
const sheetRef = useRef<HTMLDivElement>(null);
const startY = useRef(0);
const currentY = useRef(0);
const dragging = useRef(false);
useEffect(() => {
if (open) {
setVisible(true);
setClosing(false);
}
}, [open]);
const animateClose = () => {
setClosing(true);
setTimeout(() => {
setVisible(false);
setClosing(false);
onClose();
}, 250);
};
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0]!.clientY;
currentY.current = 0;
dragging.current = true;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!dragging.current) return;
const dy = e.touches[0]!.clientY - startY.current;
currentY.current = Math.max(0, dy);
if (sheetRef.current) {
sheetRef.current.style.transform = `translateY(${currentY.current}px)`;
}
};
const handleTouchEnd = () => {
dragging.current = false;
if (currentY.current > 80) {
animateClose();
} else if (sheetRef.current) {
sheetRef.current.style.transform = "";
}
};
if (!visible) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 40,
animation: closing ? "backdrop-out 250ms forwards" : "backdrop-in 200ms",
}}
onClick={animateClose}
>
<div
style={{
position: "absolute",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
}}
/>
<div
ref={sheetRef}
onClick={(e) => e.stopPropagation()}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
background: "var(--surface)",
borderRadius: "var(--r-xl) var(--r-xl) 0 0",
paddingBottom: "env(safe-area-inset-bottom, 0px)",
animation: closing ? "sheet-out 250ms forwards" : "sheet-in 250ms cubic-bezier(.22,1,.36,1)",
maxHeight: "80vh",
overflowY: "auto",
}}
>
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
background: "var(--ink-4)",
margin: "10px auto 6px",
}}
/>
{children}
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { Html5Qrcode } from "html5-qrcode";
import { Icon } from "./primitives/index.js";
export function CameraScanner({
onScan,
onClose,
}: {
onScan: (decodedText: string) => void;
onClose: () => void;
}) {
const scannerRef = useRef<Html5Qrcode | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [torchOn, setTorchOn] = useState(false);
const [torchAvailable, setTorchAvailable] = useState(false);
const lastScan = useRef("");
const lastScanTime = useRef(0);
useEffect(() => {
const id = "apothecary-scanner";
const scanner = new Html5Qrcode(id, { verbose: false });
scannerRef.current = scanner;
scanner
.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) },
(text) => {
const now = Date.now();
if (text === lastScan.current && now - lastScanTime.current < 3000) return;
lastScan.current = text;
lastScanTime.current = now;
onScan(text);
},
() => {},
)
.then(() => {
try {
const caps = scanner.getRunningTrackCameraCapabilities();
if (caps.torchFeature().isSupported()) {
setTorchAvailable(true);
}
} catch {
// torch check can fail on some browsers
}
})
.catch((err: Error) => {
setError(err.message || "Camera access denied");
});
return () => {
if (scanner.isScanning) {
scanner.stop().catch(() => {});
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const toggleTorch = () => {
try {
const caps = scannerRef.current?.getRunningTrackCameraCapabilities();
if (caps?.torchFeature().isSupported()) {
const next = !torchOn;
caps.torchFeature().apply(next);
setTorchOn(next);
}
} catch {
// ignore
}
};
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "#000",
display: "flex",
flexDirection: "column",
}}
>
{/* Top bar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "env(safe-area-inset-top, 12px) 16px 12px",
position: "relative",
zIndex: 2,
}}
>
<button
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "50%",
background: "rgba(0,0,0,0.5)",
border: "none",
cursor: "pointer",
color: "#fff",
}}
>
<Icon name="close" size={22} color="#fff" />
</button>
<div
className="serif"
style={{ color: "#fff", fontSize: 18, fontWeight: 500 }}
>
Scan
</div>
<div style={{ display: "flex", gap: 8 }}>
{torchAvailable && (
<button
onClick={toggleTorch}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "50%",
background: torchOn ? "var(--sage)" : "rgba(0,0,0,0.5)",
border: "none",
cursor: "pointer",
color: "#fff",
}}
>
<Icon name="flash" size={20} color="#fff" />
</button>
)}
</div>
</div>
{/* Camera viewport */}
<div
ref={containerRef}
style={{ flex: 1, position: "relative", overflow: "hidden" }}
>
<div
id="apothecary-scanner"
style={{ width: "100%", height: "100%" }}
/>
{error && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
color: "#fff",
textAlign: "center",
}}
>
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
<div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div>
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div>
</div>
)}
</div>
{/* Bottom hint */}
<div
style={{
padding: "16px 24px",
paddingBottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
textAlign: "center",
color: "rgba(255,255,255,0.7)",
fontSize: 14,
}}
>
Point at a barcode or QR code
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { Icon } from "./primitives/index.js";
import { NAV } from "./Sidebar.js";
import { BottomSheet } from "./BottomSheet.js";
const PRIMARY_TABS = [
{ path: "/", icon: "home", label: "Home" },
{ path: "/inventory", icon: "box", label: "Inventory" },
{ path: "/custody", icon: "pocket", label: "Custody" },
];
const MORE_NAV = NAV.filter(
(n) => !["/", "/inventory", "/custody"].includes(n.path),
);
export function MobileBottomNav({
onScan,
onAddProduct,
onMarkFinished,
onAudit,
onCheckout,
}: {
onScan: () => void;
onAddProduct: () => void;
onMarkFinished: () => void;
onAudit: () => void;
onCheckout: () => void;
}) {
const [moreOpen, setMoreOpen] = useState(false);
const location = useLocation();
const isMoreActive = MORE_NAV.some((n) => {
if (n.path === "/") return location.pathname === "/";
return location.pathname.startsWith(n.path);
});
return (
<>
<nav className="mobile-nav">
{PRIMARY_TABS.map((tab) => (
<NavLink
key={tab.path}
to={tab.path}
end={tab.path === "/"}
className={({ isActive }) =>
"mobile-nav-item" + (isActive ? " active" : "")
}
>
<Icon name={tab.icon} size={22} />
<span className="mobile-nav-label">{tab.label}</span>
</NavLink>
))}
<button
className="mobile-nav-scan"
onClick={onScan}
aria-label="Scan barcode"
>
<Icon name="barcode" size={24} color="#fff" />
</button>
<button
className={"mobile-nav-item" + (isMoreActive ? " active" : "")}
onClick={() => setMoreOpen(true)}
>
<Icon name="more" size={22} />
<span className="mobile-nav-label">More</span>
</button>
</nav>
<BottomSheet open={moreOpen} onClose={() => setMoreOpen(false)}>
<div style={{ padding: "8px 16px 12px" }}>
<div
style={{
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: "var(--ink-3)",
fontWeight: 500,
padding: "8px 8px 4px",
}}
>
Navigate
</div>
{MORE_NAV.map((n) => (
<NavLink
key={n.path}
to={n.path}
end={n.path === "/"}
onClick={() => setMoreOpen(false)}
style={({ isActive }) => ({
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 8px",
borderRadius: "var(--r-md)",
color: isActive ? "var(--ink)" : "var(--ink-2)",
fontWeight: isActive ? 600 : 500,
fontSize: 15,
textDecoration: "none",
background: isActive ? "var(--bg-2)" : "transparent",
})}
>
<Icon name={n.icon} size={20} />
{n.label}
</NavLink>
))}
<div
style={{
height: 1,
background: "var(--line)",
margin: "8px 0",
}}
/>
<div
style={{
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: "var(--ink-3)",
fontWeight: 500,
padding: "8px 8px 4px",
}}
>
Quick actions
</div>
{[
{ icon: "plus", label: "Add inventory", action: onAddProduct },
{ icon: "search", label: "Audit", action: onAudit },
{ icon: "pocket", label: "Check out", action: onCheckout },
{ icon: "check", label: "Mark consumed", action: onMarkFinished },
].map((a) => (
<button
key={a.label}
onClick={() => {
setMoreOpen(false);
a.action();
}}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 8px",
borderRadius: "var(--r-md)",
color: "var(--ink-2)",
fontSize: 15,
fontWeight: 500,
background: "transparent",
border: "none",
cursor: "pointer",
width: "100%",
textAlign: "left",
}}
>
<Icon name={a.icon} size={20} />
{a.label}
</button>
))}
</div>
</BottomSheet>
</>
);
}
+66 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import type { Bootstrap, Item, Product } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
@@ -6,6 +6,8 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
import { BottomSheet } from "./BottomSheet.js";
// Right-side drawer for an inventory instance. Shows the asset id and
// product context up top, then per-batch fields (price, THC, weight),
@@ -48,6 +50,8 @@ export function ProductDetail({
const isCheckedOut = item.status === "checked-out";
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
const isMobile = useIsMobile();
const [actionsOpen, setActionsOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -137,7 +141,7 @@ export function ProductDetail({
>
<div
style={{
padding: "20px 32px",
padding: isMobile ? "14px 16px" : "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
flexDirection: "column",
@@ -171,6 +175,12 @@ export function ProductDetail({
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Inventory · <span className="mono">{item.assetId}</span>
</div>
{isMobile ? (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<Btn variant="ghost" icon="more" onClick={() => setActionsOpen(true)} />
<Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
) : (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && (
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
@@ -203,11 +213,12 @@ export function ProductDetail({
</Btn>
<Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
)}
</div>
</div>
<div style={{ padding: "32px 32px 60px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
<div style={{ padding: isMobile ? "20px 16px 60px" : "32px 32px 60px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8, flexWrap: "wrap" }}>
<div className="serif" style={{ fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[item.type]} {item.type}
</div>
@@ -225,7 +236,7 @@ export function ProductDetail({
<h1
className="serif"
style={{
fontSize: 48,
fontSize: isMobile ? 28 : 48,
margin: "0 0 4px",
fontWeight: 500,
letterSpacing: "-0.02em",
@@ -437,7 +448,7 @@ export function ProductDetail({
<div style={{ marginTop: 36 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>Details</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 32px" }}>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: isMobile ? "0" : "14px 32px" }}>
{detailRows.map(([l, v], i) => (
<div
key={i}
@@ -498,7 +509,56 @@ export function ProductDetail({
</div>
)}
</div>
{isMobile && (
<BottomSheet open={actionsOpen} onClose={() => setActionsOpen(false)}>
<div style={{ padding: "8px 16px 20px" }}>
{isActive && (
<MobileAction icon="search" label="Audit" onClick={() => { setActionsOpen(false); onAudit(item); }} />
)}
{isActive && (
<MobileAction icon="pocket" label="Check out" onClick={() => { setActionsOpen(false); onCheckout(item); }} />
)}
{isCheckedOut && (
<MobileAction icon="pocket" label="Check in" onClick={() => { setActionsOpen(false); onCheckin(item); }} />
)}
{(isActive || isCheckedOut) && (
<MobileAction icon="leaf" label="Mark consumed" onClick={() => { setActionsOpen(false); onConsume(item); }} />
)}
{(isActive || isCheckedOut) && (
<MobileAction icon="bin" label="Mark gone" onClick={() => { setActionsOpen(false); onMarkGone(item); }} />
)}
<MobileAction icon="edit" label="Edit" onClick={() => { setActionsOpen(false); onEdit(item); }} />
</div>
</BottomSheet>
)}
</div>
</div>
);
}
function MobileAction({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 8px",
borderRadius: "var(--r-md)",
color: "var(--ink-2)",
fontSize: 15,
fontWeight: 500,
background: "transparent",
border: "none",
cursor: "pointer",
width: "100%",
textAlign: "left",
}}
>
<Icon name={icon} size={20} />
{label}
</button>
);
}
+175
View File
@@ -0,0 +1,175 @@
import type { Item, Product, Bootstrap } from "../types.js";
import { helpers } from "../types.js";
import { remainingShort } from "../stats.js";
import { TYPE_GLYPHS } from "../format.js";
import { Icon, Pill } from "./primitives/index.js";
import { BottomSheet } from "./BottomSheet.js";
import type { ScanResult } from "./ScanField.js";
export function ScanAction({
result,
data,
noMatchText,
onClose,
onViewItem,
onAudit,
onCheckout,
onCheckin,
onConsume,
onMarkGone,
onAddInventory,
onViewSku,
onCreateProduct,
}: {
result: ScanResult | null;
data: Bootstrap;
noMatchText: string | null;
onClose: () => void;
onViewItem: (item: Item) => void;
onAudit: (item: Item) => void;
onCheckout: (item: Item) => void;
onCheckin: (item: Item) => void;
onConsume: (item: Item) => void;
onMarkGone: (item: Item) => void;
onAddInventory: () => void;
onViewSku: (product: Product) => void;
onCreateProduct: (sku: string) => void;
}) {
const open = result !== null || noMatchText !== null;
if (!open) return null;
if (noMatchText) {
return (
<BottomSheet open onClose={onClose}>
<div style={{ padding: "12px 16px 20px" }}>
<div style={{ textAlign: "center", padding: "12px 0 16px" }}>
<div style={{ fontSize: 15, fontWeight: 500, color: "var(--ink)" }}>
No match found
</div>
<div
className="mono"
style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 4 }}
>
{noMatchText}
</div>
</div>
<ActionButton
icon="plus"
label="Create new product with this SKU"
onClick={() => {
onClose();
onCreateProduct(noMatchText);
}}
/>
</div>
</BottomSheet>
);
}
if (result?.kind === "item") {
const item = result.item;
const brand = helpers.brandName(data, item.brandId);
const overdue = helpers.auditOverdue(item, new Date().toISOString().slice(0, 10));
return (
<BottomSheet open onClose={onClose}>
<div style={{ padding: "12px 16px 20px" }}>
{/* Item header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0 16px" }}>
<div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[item.type]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, fontSize: 16, color: "var(--ink)" }}>
{item.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", gap: 8, alignItems: "center" }}>
<span>{brand}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">{item.assetId}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">{remainingShort(item)}</span>
{overdue && <Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>}
</div>
</div>
</div>
{/* Actions */}
<ActionButton icon="arrow" label="View details" onClick={() => { onClose(); onViewItem(item); }} />
<ActionButton icon="search" label="Audit" onClick={() => { onClose(); onAudit(item); }} />
{item.status === "active" && (
<ActionButton icon="pocket" label="Check out" onClick={() => { onClose(); onCheckout(item); }} />
)}
{item.status === "checked-out" && (
<ActionButton icon="pocket" label="Check in" onClick={() => { onClose(); onCheckin(item); }} />
)}
{item.status === "active" && (
<ActionButton icon="check" label="Mark consumed" onClick={() => { onClose(); onConsume(item); }} />
)}
{item.status === "active" && (
<ActionButton icon="close" label="Mark gone" onClick={() => { onClose(); onMarkGone(item); }} />
)}
</div>
</BottomSheet>
);
}
if (result?.kind === "product") {
const product = result.product;
return (
<BottomSheet open onClose={onClose}>
<div style={{ padding: "12px 16px 20px" }}>
<div style={{ textAlign: "center", padding: "8px 0 16px" }}>
<div style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--ink-3)", marginBottom: 4 }}>
{TYPE_GLYPHS[product.type]}
</div>
<div style={{ fontWeight: 500, fontSize: 16, color: "var(--ink)" }}>
SKU matched
</div>
<div className="mono" style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 2 }}>
{product.sku}
</div>
</div>
<ActionButton icon="plus" label="Add inventory of this product" onClick={() => { onClose(); onAddInventory(); }} />
<ActionButton icon="barcode" label="View SKU details" onClick={() => { onClose(); onViewSku(product); }} />
</div>
</BottomSheet>
);
}
return null;
}
function ActionButton({
icon,
label,
onClick,
}: {
icon: string;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 8px",
borderRadius: "var(--r-md)",
color: "var(--ink-2)",
fontSize: 15,
fontWeight: 500,
background: "transparent",
border: "none",
cursor: "pointer",
width: "100%",
textAlign: "left",
}}
>
<Icon name={icon} size={20} />
{label}
</button>
);
}
+1 -1
View File
@@ -125,7 +125,7 @@ export function ScanField({
);
}
function lookup(
export function lookup(
trimmed: string,
items: Item[],
products?: Product[],
+1 -1
View File
@@ -12,7 +12,7 @@ export type ViewKey =
| "charts"
| "settings";
const NAV: { path: string; label: string; icon: string }[] = [
export const NAV: { path: string; label: string; icon: string }[] = [
{ path: "/", label: "Dashboard", icon: "home" },
{ path: "/inventory", label: "Inventory", icon: "box" },
{ path: "/skus", label: "SKUs", icon: "barcode" },
+6 -2
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { Icon } from "./primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
type ToastType = "success" | "error";
@@ -56,6 +57,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
startTimer(id);
}, [startTimer]);
const isMobile = useIsMobile();
return (
<ToastContext.Provider value={{ toast }}>
{children}
@@ -65,8 +68,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
aria-live="polite"
style={{
position: "fixed",
bottom: 24,
right: 24,
bottom: isMobile ? "calc(72px + env(safe-area-inset-bottom, 0px) + 12px)" : 24,
right: isMobile ? 12 : 24,
left: isMobile ? 12 : "auto",
zIndex: 100,
display: "flex",
flexDirection: "column",
+20 -5
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useRef } from "react";
import { useExitAnimation } from "../../hooks/useExitAnimation.js";
import { useIsMobile } from "../../hooks/useIsMobile.js";
import { Btn } from "../primitives/index.js";
const ModalCloseCtx = createContext<(() => void) | null>(null);
@@ -14,6 +15,7 @@ export function ModalBackdrop({
const backdropRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<Element | null>(null);
const { closing, triggerClose } = useExitAnimation(180, onClose);
const isMobile = useIsMobile();
useEffect(() => {
previousFocus.current = document.activeElement;
@@ -77,7 +79,17 @@ export function ModalBackdrop({
<ModalCloseCtx.Provider value={triggerClose}>
<div
onClick={(e) => e.stopPropagation()}
style={{
style={isMobile ? {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
maxHeight: "95vh",
overflowY: "auto",
background: "var(--surface)",
borderRadius: "var(--r-xl) var(--r-xl) 0 0",
animation: closing ? "sheet-out 180ms ease-in forwards" : "sheet-in 200ms cubic-bezier(.22,1,.36,1)",
} : {
width: "100%",
display: "flex",
justifyContent: "center",
@@ -103,10 +115,11 @@ export function ModalHeader({
onClose: () => void;
}) {
const animatedClose = useContext(ModalCloseCtx);
const isMobile = useIsMobile();
return (
<div
style={{
padding: "20px 32px",
padding: isMobile ? "16px 20px" : "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
@@ -119,7 +132,7 @@ export function ModalHeader({
{eyebrow}
</div>
)}
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
<h2 className="serif" style={{ fontSize: isMobile ? 22 : 28, margin: "4px 0 0", fontWeight: 500 }}>
{title}
</h2>
</div>
@@ -129,16 +142,18 @@ export function ModalHeader({
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
return (
<div
style={{
padding: "16px 32px",
padding: isMobile ? "16px 20px" : "16px 32px",
paddingBottom: isMobile ? "calc(16px + env(safe-area-inset-bottom, 0px))" : "16px",
borderTop: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "var(--bg-2)",
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
borderRadius: isMobile ? "0" : "0 0 var(--r-lg) var(--r-lg)",
}}
>
{children}
+4
View File
@@ -26,6 +26,10 @@ const ICON_PATHS: Record<string, string> = {
pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5",
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
barcode: "M4 4v16M8 4v16M11 4v16M14 4v16M18 4v16M20 4v16",
more: "M12 5h.01M12 12h.01M12 19h.01",
camera: "M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2v11zM12 17a4 4 0 100-8 4 4 0 000 8z",
flash: "M13 2L3 14h9l-1 8 10-12h-9l1-8z",
flipCamera: "M16 3h5v5M8 21H3v-5M21 3l-7 7M3 21l7-7",
};
export function Icon({
+17
View File
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
const QUERY = "(max-width: 880px)";
function subscribe(cb: () => void) {
const mql = window.matchMedia(QUERY);
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
}
function getSnapshot() {
return window.matchMedia(QUERY).matches;
}
export function useIsMobile() {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from "react";
export function usePullToRefresh(onRefresh: () => Promise<void> | void) {
const [pulling, setPulling] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const startY = useRef(0);
const pullDistance = useRef(0);
const indicatorRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let active = false;
const onTouchStart = (e: TouchEvent) => {
if (window.scrollY > 0) return;
startY.current = e.touches[0]!.clientY;
active = true;
};
const onTouchMove = (e: TouchEvent) => {
if (!active || window.scrollY > 0) {
active = false;
return;
}
const dy = e.touches[0]!.clientY - startY.current;
if (dy < 0) return;
pullDistance.current = Math.min(dy, 120);
if (pullDistance.current > 10) {
setPulling(true);
}
};
const onTouchEnd = async () => {
if (!active) return;
active = false;
if (pullDistance.current > 60) {
setRefreshing(true);
setPulling(false);
await onRefresh();
setRefreshing(false);
} else {
setPulling(false);
}
pullDistance.current = 0;
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
};
}, [onRefresh]);
return { pulling, refreshing, indicatorRef };
}
+67 -36
View File
@@ -126,6 +126,14 @@
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes sheet-out {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
@keyframes backdrop-out {
from { opacity: 1; }
to { opacity: 0; }
@@ -151,51 +159,74 @@
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.main {
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}
input, select, textarea {
font-size: 16px !important;
}
.bulk-toolbar {
left: 0 !important;
bottom: calc(72px + env(safe-area-inset-bottom, 0px)) !important;
}
}
/* Mobile bottom nav */
.mobile-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
height: auto;
flex-direction: row;
padding: 8px 12px;
height: calc(64px + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--surface);
border-top: 1px solid var(--line);
border-right: none;
z-index: 30;
overflow-x: auto;
}
.brand,
.nav-section {
display: none;
}
.nav-link {
white-space: nowrap;
padding: 8px 12px;
flex-direction: column;
font-size: 10px;
gap: 2px;
display: flex;
align-items: center;
justify-content: space-around;
z-index: 30;
}
.nav-label {
display: none;
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-width: 48px;
min-height: 44px;
padding: 6px 12px;
color: var(--ink-3);
background: transparent;
border: none;
cursor: pointer;
text-decoration: none;
transition: color 100ms;
}
.nav-divider {
display: block;
width: 1px;
height: 24px;
background: var(--line);
flex-shrink: 0;
align-self: center;
.mobile-nav-item.active {
color: var(--sage);
}
.nav-action {
border-style: solid;
border-color: var(--line);
.mobile-nav-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
}
.main {
padding-bottom: 60px;
}
.bulk-toolbar {
left: 0 !important;
bottom: 60px !important;
.mobile-nav-scan {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--sage);
border: 3px solid var(--surface);
color: #fff;
cursor: pointer;
margin-top: -20px;
box-shadow: var(--shadow-md);
transition: transform 100ms;
}
.mobile-nav-scan:active {
transform: scale(0.93);
}
+3
View File
@@ -43,6 +43,9 @@
--r-lg: 14px;
--r-xl: 20px;
/* Touch targets */
--touch-min: 44px;
/* Shadow — subtle */
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
--shadow-md: 0 2px 8px oklch(20% 0.02 60 / 0.08);
+9 -5
View File
@@ -5,6 +5,7 @@ import type { Stats } from "../stats.js";
import { remainingShort } from "../stats.js";
import { fmt } from "../format.js";
import { Btn, Card, Stat, Pill, Sparkline, BarChart, Donut } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
const TYPE_COLORS: Record<string, string> = {
@@ -29,6 +30,7 @@ export function Dashboard({
onAuditQueue: (items: Item[]) => void;
onSelectItem: (i: Item) => void;
}) {
const isMobile = useIsMobile();
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
const last7Series = stats.series7.map((l) => l.grams);
const last30Series = stats.series30.map((d) => d.grams);
@@ -56,17 +58,19 @@ export function Dashboard({
return (
<div
style={{
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
padding: isMobile
? "20px 16px 80px"
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
maxWidth: 2400,
margin: "0 auto",
}}
>
<div style={{ marginBottom: 24 }}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{greetingDate}</div>
<h1
className="serif"
style={{
fontSize: 48,
fontSize: isMobile ? 28 : 48,
margin: "8px 0 0",
fontWeight: 500,
letterSpacing: "-0.02em",
@@ -101,7 +105,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
gap: 18,
marginBottom: 18,
}}
@@ -139,7 +143,7 @@ export function Dashboard({
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(auto-fit, minmax(260px, 1fr))",
gap: 18,
marginBottom: 18,
}}
+451 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Bootstrap, Item } from "../types.js";
import { TYPES, helpers, enrichItems } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
@@ -7,6 +7,8 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js";
import { useSelection } from "../hooks/useSelection.js";
import { BulkToolbar } from "../components/BulkToolbar.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
import { BottomSheet } from "../components/BottomSheet.js";
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
@@ -36,6 +38,7 @@ export function Inventory({
onBulkGone: (items: Item[]) => void;
}) {
const items = useMemo(() => enrichItems(data), [data]);
const isMobile = useIsMobile();
const [filter, setFilter] = useState<FilterKey>("active");
const [typeFilter, setTypeFilter] = useState<string>("all");
@@ -147,33 +150,139 @@ export function Inventory({
[items, selected],
);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (selected.size === 0) setSelectionMode(false);
}, [selected.size]);
return (
<div
style={{
padding: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
padding: isMobile
? "20px 16px 80px"
: "clamp(32px, 3vw, 64px) clamp(40px, 3vw, 80px) 80px",
maxWidth: 2400,
margin: "0 auto",
paddingBottom: selected.size > 0 ? 140 : 80,
paddingBottom: selected.size > 0 ? 140 : isMobile ? 80 : 80,
}}
>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: isMobile ? 16 : 24 }}>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{sorted.length} item{sorted.length === 1 ? "" : "s"}
</div>
<h1
className="serif"
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
style={{ fontSize: isMobile ? 28 : 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
>
Inventory
</h1>
</div>
{!isMobile && (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="secondary" icon="check" onClick={onAuditNew}>Audit</Btn>
<Btn variant="primary" icon="plus" onClick={onAddInventory}>Add inventory</Btn>
</div>
)}
</div>
{/* Filter bar */}
{isMobile ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
{/* Status filter pills - horizontally scrollable */}
<div style={{ display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2 }}>
{(
[
["active", "Active"],
["checked-out", "Checked out"],
["consumed", "Consumed"],
["gone", "Gone"],
["all", "All"],
] as [FilterKey, string][]
).map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
style={{
padding: "7px 14px",
fontSize: 13,
fontWeight: 500,
borderRadius: 20,
border: filter === k ? "1px solid var(--sage)" : "1px solid var(--line)",
background: filter === k ? "var(--sage-soft)" : "var(--surface)",
color: filter === k ? "var(--sage)" : "var(--ink-2)",
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{l}
</button>
))}
</div>
{/* Search + filter/sort buttons */}
<div style={{ display: "flex", gap: 8 }}>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "0 10px",
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "10px 0",
fontSize: 16,
flex: 1,
color: "var(--ink)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{ background: "transparent", border: "none", cursor: "pointer", padding: 4, display: "inline-flex", color: "var(--ink-3)" }}
>
<Icon name="close" size={14} />
</button>
)}
</div>
<button
onClick={() => setMobileFilterOpen(true)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "var(--r-md)",
border: "1px solid var(--line)",
background: (typeFilter !== "all" || sortBy !== "recent") ? "var(--sage-soft)" : "var(--surface)",
cursor: "pointer",
color: "var(--ink-2)",
flexShrink: 0,
}}
>
<Icon name="filter" size={18} />
</button>
</div>
</div>
) : (
<Card style={{ marginBottom: 14, padding: 14 }}>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<Segmented<FilterKey>
@@ -275,7 +384,57 @@ export function Inventory({
</Select>
</div>
</Card>
)}
{/* Item list */}
{isMobile ? (
<Card padded={false}>
{sorted.length === 0 && (
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 14 }}>
No items match these filters.
</div>
)}
{view === "flat" &&
sorted.map((i) => (
<MobileItemCard
key={i.id}
i={i}
data={data}
onSelect={onSelectItem}
isSelected={selected.has(i.id)}
selectionMode={selectionMode}
onLongPress={() => {
setSelectionMode(true);
toggle(i.id, false);
}}
onToggle={() => toggle(i.id, false)}
longPressTimer={longPressTimer}
/>
))}
{view === "grouped" &&
groups.map((g) => (
<div key={g.productId}>
<MobileGroupHeader group={g} />
{g.items.map((i) => (
<MobileItemCard
key={i.id}
i={i}
data={data}
onSelect={onSelectItem}
isSelected={selected.has(i.id)}
selectionMode={selectionMode}
onLongPress={() => {
setSelectionMode(true);
toggle(i.id, false);
}}
onToggle={() => toggle(i.id, false)}
longPressTimer={longPressTimer}
/>
))}
</div>
))}
</Card>
) : (
<Card padded={false}>
<HeaderRow
sortBy={sortBy}
@@ -336,6 +495,7 @@ export function Inventory({
);
})}
</Card>
)}
{selected.size > 0 && (
<BulkToolbar
@@ -349,6 +509,117 @@ export function Inventory({
onBulkGone={() => onBulkGone(selectedItems)}
/>
)}
{/* Mobile filter/sort bottom sheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)}>
<div style={{ padding: "8px 16px 20px" }}>
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
View
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{(["flat", "grouped"] as ViewKey[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
style={{
flex: 1,
padding: "10px 14px",
fontSize: 14,
fontWeight: 500,
borderRadius: "var(--r-md)",
border: view === v ? "1px solid var(--sage)" : "1px solid var(--line)",
background: view === v ? "var(--sage-soft)" : "var(--surface)",
color: view === v ? "var(--sage)" : "var(--ink-2)",
cursor: "pointer",
}}
>
{v === "flat" ? "Flat" : "By product"}
</button>
))}
</div>
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
Type
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 16 }}>
<button
onClick={() => setTypeFilter("all")}
style={{
padding: "8px 14px",
fontSize: 13,
borderRadius: 20,
border: typeFilter === "all" ? "1px solid var(--sage)" : "1px solid var(--line)",
background: typeFilter === "all" ? "var(--sage-soft)" : "var(--surface)",
color: typeFilter === "all" ? "var(--sage)" : "var(--ink-2)",
cursor: "pointer",
}}
>
All
</button>
{TYPES.map((t) => (
<button
key={t.id}
onClick={() => setTypeFilter(t.id)}
style={{
padding: "8px 14px",
fontSize: 13,
borderRadius: 20,
border: typeFilter === t.id ? "1px solid var(--sage)" : "1px solid var(--line)",
background: typeFilter === t.id ? "var(--sage-soft)" : "var(--surface)",
color: typeFilter === t.id ? "var(--sage)" : "var(--ink-2)",
cursor: "pointer",
}}
>
{TYPE_GLYPHS[t.id]} {t.id}
</button>
))}
</div>
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--ink-3)", fontWeight: 500, padding: "8px 0 8px" }}>
Sort by
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{(
[
["recent", "Recent first"],
["name", "Name (AZ)"],
["thc", "THC %"],
["remaining", "Remaining"],
["price", "Price"],
["audit", "Audit overdue"],
] as [SortKey, string][]
).map(([k, l]) => (
<button
key={k}
onClick={() => {
if (k === sortBy) {
setSortAsc((prev) => !prev);
} else {
setSortBy(k);
setSortAsc(false);
}
}}
style={{
padding: "12px 8px",
fontSize: 15,
fontWeight: sortBy === k ? 600 : 400,
borderRadius: "var(--r-md)",
border: "none",
background: sortBy === k ? "var(--bg-2)" : "transparent",
color: sortBy === k ? "var(--ink)" : "var(--ink-2)",
cursor: "pointer",
textAlign: "left",
display: "flex",
justifyContent: "space-between",
}}
>
{l}
{sortBy === k && <span style={{ fontSize: 12 }}>{sortAsc ? "▲" : "▼"}</span>}
</button>
))}
</div>
</div>
</BottomSheet>
</div>
);
}
@@ -716,3 +987,178 @@ function ItemRow({
</div>
);
}
function MobileItemCard({
i,
data,
onSelect,
isSelected,
selectionMode,
onLongPress,
onToggle,
longPressTimer,
}: {
i: Item;
data: Bootstrap;
onSelect: (i: Item) => void;
isSelected: boolean;
selectionMode: boolean;
onLongPress: () => void;
onToggle: () => void;
longPressTimer: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
}) {
const pctRemaining = helpers.pctRemaining(i);
const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone()));
const isInactive = i.status !== "active" && i.status !== "checked-out";
const brand = helpers.brandName(data, i.brandId);
const cfg = TYPES.find((t) => t.id === i.type);
const handlePointerDown = () => {
longPressTimer.current = setTimeout(onLongPress, 400);
};
const handlePointerUp = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
return (
<div
onClick={() => {
if (selectionMode) {
onToggle();
} else {
onSelect(i);
}
}}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
borderBottom: "1px solid var(--line)",
cursor: "pointer",
opacity: isInactive ? 0.55 : 1,
background: isSelected ? "var(--sage-soft)" : undefined,
minHeight: 64,
WebkitTapHighlightColor: "transparent",
userSelect: "none",
}}
>
{selectionMode && (
<div style={{ flexShrink: 0 }}>
<Checkbox checked={isSelected} onChange={() => {}} />
</div>
)}
<div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--ink-3)", width: 22, flexShrink: 0, textAlign: "center" }}>
{TYPE_GLYPHS[i.type]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}>
<span
style={{
fontWeight: 500,
color: "var(--ink)",
fontSize: 15,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{i.name}
</span>
{i.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10, flexShrink: 0 }}>Consumed</Pill>}
{i.status === "gone" && <Pill tone="amber" style={{ fontSize: 10, flexShrink: 0 }}>Gone</Pill>}
{i.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10, flexShrink: 0 }}>Out</Pill>}
{i.status === "active" && overdue && <Pill tone="amber" style={{ fontSize: 10, flexShrink: 0 }}>Audit</Pill>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--ink-3)" }}>
<span>{brand}</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">
{cfg?.showCannabinoidPct !== false ? `${i.thc.toFixed(1)}%` : `${i.unitWeight}mg`}
</span>
<span style={{ color: "var(--ink-4)" }}>·</span>
<span className="mono">{remainingShort(i)}</span>
</div>
</div>
{i.status === "active" && i.kind === "bulk" && (
<div style={{ width: 40, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", gap: 2 }}>
<div style={{ width: "100%", height: 4, background: "var(--bg-3)", borderRadius: 2 }}>
<div
style={{
width: `${pctRemaining * 100}%`,
height: "100%",
background:
pctRemaining < 0.25 ? "var(--terracotta)"
: pctRemaining < 0.5 ? "var(--amber)"
: "var(--sage)",
borderRadius: 2,
}}
/>
</div>
</div>
)}
{!selectionMode && (
<span style={{ color: "var(--ink-4)", fontSize: 16, flexShrink: 0 }}></span>
)}
</div>
);
}
function MobileGroupHeader({
group,
}: {
group: {
productId: string;
label: string;
sku: string;
type: string;
items: Item[];
};
}) {
const active = group.items.filter((i) => i.status === "active");
const cfg = TYPES.find((t) => t.id === group.type);
const totalRemaining = active.reduce((s, i) => {
if (i.kind === "bulk") return s + helpers.remaining(i);
return s + (i.countLastAudit ?? i.countOriginal);
}, 0);
return (
<div
style={{
padding: "14px 16px 10px",
borderBottom: "1px solid var(--line)",
background: "var(--bg-2)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
<span style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)" }}>
{TYPE_GLYPHS[group.type]}
</span>
<span className="serif" style={{ fontSize: 18, fontWeight: 500 }}>
{group.label}
</span>
</div>
<div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ink-3)" }}>
<span>
<span className="mono" style={{ color: "var(--ink-2)" }}>{active.length}</span> active
</span>
<span>
<span className="mono" style={{ color: "var(--ink-2)" }}>
{totalRemaining.toFixed(2).replace(/\.?0+$/, "") || "0"}
</span>{" "}
{cfg?.unit ?? "g"}
</span>
</div>
</div>
);
}
+48 -1
View File
@@ -1,8 +1,55 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "Apothecary",
short_name: "Apothecary",
description: "Personal cannabis inventory tracker",
theme_color: "#f5efe6",
background_color: "#f5efe6",
display: "standalone",
scope: "/",
start_url: "/",
icons: [
{ src: "/icons/icon-192.svg", sizes: "192x192", type: "image/svg+xml" },
{ src: "/icons/icon-512.svg", sizes: "512x512", type: "image/svg+xml" },
{ src: "/icons/maskable-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" },
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,woff2}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com/,
handler: "CacheFirst",
options: { cacheName: "google-fonts-stylesheets" },
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com/,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-webfonts",
expiration: { maxEntries: 10 },
},
},
{
urlPattern: /\/api\//,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
networkTimeoutSeconds: 5,
},
},
],
},
}),
],
server: {
port: 5173,
proxy: {