406 lines
19 KiB
HTML
406 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Apothecary — Personal Inventory</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
<link rel="stylesheet" href="tokens.css" />
|
|
<style>
|
|
/* App chrome */
|
|
.app-shell {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-columns: 220px 1fr;
|
|
}
|
|
.sidebar {
|
|
border-right: 1px solid var(--line);
|
|
background: var(--bg-2);
|
|
padding: 28px 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
overflow: auto;
|
|
}
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 0 8px 28px;
|
|
}
|
|
.brand-mark {
|
|
width: 32px; height: 32px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--ink);
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: var(--serif);
|
|
font-size: 18px;
|
|
font-style: italic;
|
|
color: var(--ink);
|
|
}
|
|
.nav-link {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 9px 12px;
|
|
border-radius: var(--r-md);
|
|
color: var(--ink-2);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
border: none;
|
|
background: transparent;
|
|
text-align: left;
|
|
transition: background 100ms;
|
|
}
|
|
.nav-link:hover { background: var(--bg-3); color: var(--ink); }
|
|
.nav-link.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); border: 1px solid var(--line); }
|
|
.nav-section {
|
|
font-size: 10px; text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
color: var(--ink-3);
|
|
padding: 16px 12px 6px;
|
|
font-weight: 500;
|
|
}
|
|
.inv-row:hover { background: var(--bg-2); }
|
|
|
|
@media (max-width: 880px) {
|
|
.app-shell { grid-template-columns: 1fr; }
|
|
.sidebar {
|
|
position: fixed; bottom: 0; left: 0; right: 0; top: auto;
|
|
height: auto;
|
|
flex-direction: row;
|
|
padding: 8px 12px;
|
|
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; }
|
|
.main { padding-bottom: 60px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
|
|
|
<script src="data.js?v=4"></script>
|
|
<script type="text/babel" src="primitives.jsx?v=4"></script>
|
|
<script type="text/babel" src="screens-1.jsx?v=4"></script>
|
|
<script type="text/babel" src="screens-2.jsx?v=4"></script>
|
|
<script type="text/babel" src="tweaks-panel.jsx?v=4"></script>
|
|
<script type="text/babel">
|
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|
"theme": "light",
|
|
"dashboard": "editorial",
|
|
"tone": "botanical",
|
|
"accent": "sage"
|
|
}/*EDITMODE-END*/;
|
|
|
|
const NAV = [
|
|
{ key: "dashboard", label: "Dashboard", icon: "home" },
|
|
{ key: "inventory", label: "Inventory", icon: "box" },
|
|
{ key: "bins", label: "Bins", icon: "bin" },
|
|
{ key: "charts", label: "Patterns", icon: "chart" },
|
|
{ key: "settings", label: "Settings", icon: "settings" }
|
|
];
|
|
|
|
const TONE_LABELS = {
|
|
botanical: { brand: "Apothecary", tagline: "A personal log" },
|
|
neutral: { brand: "Inventory", tagline: "Personal stockkeeping" },
|
|
discreet: { brand: "The Cabinet", tagline: "Private register" }
|
|
};
|
|
|
|
function App() {
|
|
const [tweaks, setTweaks] = useTweaks(TWEAK_DEFAULTS);
|
|
const [view, setView] = React.useState("dashboard");
|
|
const [selected, setSelected] = React.useState(null);
|
|
const [modal, setModal] = React.useState(null); // 'add' | 'consume' | 'gone' | 'audit'
|
|
const [modalProduct, setModalProduct] = React.useState(null);
|
|
const [tweaksOpen, setTweaksOpen] = React.useState(false);
|
|
|
|
const data = window.SAMPLE_DATA;
|
|
const stats = React.useMemo(() => computeStats(data), [data]);
|
|
|
|
// Apply theme
|
|
React.useEffect(() => {
|
|
document.documentElement.setAttribute("data-theme", tweaks.theme);
|
|
}, [tweaks.theme]);
|
|
|
|
// Tweaks toggle wiring
|
|
React.useEffect(() => {
|
|
const handler = (e) => {
|
|
if (e.data?.type === "__activate_edit_mode") setTweaksOpen(true);
|
|
if (e.data?.type === "__deactivate_edit_mode") setTweaksOpen(false);
|
|
};
|
|
window.addEventListener("message", handler);
|
|
window.parent.postMessage({type: "__edit_mode_available"}, "*");
|
|
return () => window.removeEventListener("message", handler);
|
|
}, []);
|
|
|
|
const labels = TONE_LABELS[tweaks.tone] || TONE_LABELS.botanical;
|
|
|
|
const onNav = (target) => {
|
|
if (target === "add") { setModalProduct(null); setModal("add"); }
|
|
else if (target === "consume") { setModalProduct(null); setModal("consume"); }
|
|
else if (target === "gone") { setModalProduct(null); setModal("gone"); }
|
|
else if (target === "audit") { setModalProduct(null); setModal("audit"); }
|
|
else setView(target);
|
|
};
|
|
const openAudit = (p) => { setModalProduct(p); setModal("audit"); };
|
|
const openGone = (p) => { setModalProduct(p); setSelected(null); setModal("gone"); };
|
|
const openConsume = (p) => { setModalProduct(p); setSelected(null); setModal("consume"); };
|
|
|
|
const handleSave = () => {
|
|
setModal(null);
|
|
};
|
|
|
|
return (
|
|
<div className="app-shell" data-screen-label="App">
|
|
{/* Sidebar */}
|
|
<aside className="sidebar">
|
|
<div className="brand">
|
|
<div className="brand-mark">A</div>
|
|
<div>
|
|
<div className="serif" style={{fontSize: 18, fontWeight: 500, lineHeight: 1}}>{labels.brand}</div>
|
|
<div style={{fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.1em"}}>{labels.tagline}</div>
|
|
</div>
|
|
</div>
|
|
<div className="nav-section">Workspace</div>
|
|
{NAV.map(n => (
|
|
<button key={n.key} className={"nav-link " + (view === n.key ? "active" : "")} onClick={() => setView(n.key)}>
|
|
<Icon name={n.icon} size={16} />
|
|
{n.label}
|
|
</button>
|
|
))}
|
|
<div className="nav-section">Quick</div>
|
|
<button className="nav-link" onClick={() => setModal("add")}>
|
|
<Icon name="plus" size={16} /> Add product
|
|
</button>
|
|
<button className="nav-link" onClick={() => setModal("consume")}>
|
|
<Icon name="check" size={16} /> Mark finished
|
|
</button>
|
|
|
|
<div style={{flex: 1}} />
|
|
<div style={{padding: 12, fontSize: 11, color: "var(--ink-3)", borderTop: "1px solid var(--line)", marginTop: 12}}>
|
|
<div className="mono">v0.4 · {data.products.length} items</div>
|
|
<div style={{marginTop: 4}}>Local-only · Apr 2026</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main className="main parchment" style={{minWidth: 0}}>
|
|
{view === "dashboard" && (
|
|
<DashboardSwitch
|
|
variant={tweaks.dashboard}
|
|
data={data}
|
|
stats={stats}
|
|
onNav={onNav}
|
|
onSelectProduct={setSelected}
|
|
onAudit={openAudit}
|
|
/>
|
|
)}
|
|
{view === "inventory" && <Inventory data={data} onSelectProduct={setSelected} onNav={onNav} />}
|
|
{view === "bins" && <BinsView data={data} onSelectProduct={setSelected} />}
|
|
{view === "charts" && <ChartsView data={data} stats={stats} />}
|
|
{view === "settings" && <SettingsView data={data} tweaks={tweaks} onTweakChange={(k,v) => setTweaks({...tweaks, [k]: v})} />}
|
|
</main>
|
|
|
|
{selected && (
|
|
<ProductDetail
|
|
product={selected}
|
|
data={data}
|
|
onClose={() => setSelected(null)}
|
|
onConsume={openConsume}
|
|
onMarkGone={openGone}
|
|
onAudit={openAudit}
|
|
onEdit={() => {}}
|
|
/>
|
|
)}
|
|
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} onSave={handleSave} />}
|
|
{modal === "consume" && <ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
{modal === "gone" && <MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
{modal === "audit" && <AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
|
|
|
|
{tweaksOpen && (
|
|
<TweaksPanel onClose={() => setTweaksOpen(false)} title="Tweaks">
|
|
<TweakSection title="Appearance">
|
|
<TweakRadio label="Theme" value={tweaks.theme} options={[["light","Light"],["dark","Dark"]]} onChange={v => setTweaks({...tweaks, theme: v})} />
|
|
<TweakRadio label="Dashboard layout" value={tweaks.dashboard} options={[["editorial","Editorial"],["dense","Data-dense"],["minimal","Minimal"]]} onChange={v => setTweaks({...tweaks, dashboard: v})} />
|
|
</TweakSection>
|
|
<TweakSection title="Copy">
|
|
<TweakRadio label="Tone" value={tweaks.tone} options={[["botanical","Botanical"],["neutral","Neutral"],["discreet","Discreet"]]} onChange={v => setTweaks({...tweaks, tone: v})} />
|
|
</TweakSection>
|
|
</TweaksPanel>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Switches between 3 dashboard variants
|
|
const DashboardSwitch = ({variant, ...props}) => {
|
|
if (variant === "dense") return <DashboardDense {...props} />;
|
|
if (variant === "minimal") return <DashboardMinimal {...props} />;
|
|
return <Dashboard {...props} />;
|
|
};
|
|
|
|
// Variation 2: data-dense
|
|
const DashboardDense = ({data, stats, onNav, onSelectProduct}) => {
|
|
const series = stats.series90.map(s => ({date: s.date, value: s.grams}));
|
|
return (
|
|
<div style={{padding: "20px 28px 60px"}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18, paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
|
|
<div style={{display: "flex", alignItems: "baseline", gap: 16}}>
|
|
<h1 className="mono" style={{fontSize: 13, margin: 0, color: "var(--ink-2)", textTransform: "uppercase", letterSpacing: "0.08em"}}>Dashboard / 2026-04-25</h1>
|
|
<span style={{fontSize: 11, color: "var(--ink-3)"}}>· {stats.activeCount} active · {stats.consumedCount} archived</span>
|
|
</div>
|
|
<div style={{display: "flex", gap: 6}}>
|
|
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Product</Btn>
|
|
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Finish</Btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 1, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden", marginBottom: 14}}>
|
|
{[
|
|
["DAILY g", stats.dailyAvg.toFixed(2)],
|
|
["WEEKLY g", stats.weeklyAvg.toFixed(1)],
|
|
["MONTHLY g", stats.monthlyAvg.toFixed(1)],
|
|
["AVG $/g", fmt.money(stats.avgPerGram)],
|
|
["30D SPEND", fmt.moneyShort(stats.spend30)],
|
|
["INV VALUE", fmt.moneyShort(stats.inventoryValue)],
|
|
["7D THC mg", stats.thcLast7],
|
|
["DAYS SUPPLY", Math.round(stats.daysOfSupply)]
|
|
].map(([l, v]) => (
|
|
<div key={l} style={{padding: "12px 14px", background: "var(--surface)"}}>
|
|
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.04em"}}>{l}</div>
|
|
<div className="mono" style={{fontSize: 20, marginTop: 4, fontWeight: 500}}>{v}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
|
|
<Card padded={false}>
|
|
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between"}}>
|
|
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>g / day · 90d</span>
|
|
<span className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{series.reduce((s,e)=>s+e.value,0).toFixed(1)} g total</span>
|
|
</div>
|
|
<div style={{padding: 14}}>
|
|
<BarChart data={series} height={120} color="var(--sage)" />
|
|
</div>
|
|
</Card>
|
|
<Card padded={false}>
|
|
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>recent purchases</span>
|
|
</div>
|
|
<div>
|
|
{[...data.products].sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
|
|
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: "pointer", fontSize: 12}}>
|
|
<div>
|
|
<div style={{fontWeight: 500, fontSize: 13}}>{p.name}</div>
|
|
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)}</div>
|
|
</div>
|
|
<div className="mono" style={{textAlign: "right"}}>
|
|
<div>{fmt.money(p.price)}</div>
|
|
<div style={{fontSize: 10, color: "var(--ink-3)"}}>{fmt.dateShort(p.purchaseDate)}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card padded={false}>
|
|
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>active inventory · {stats.activeCount} rows</span>
|
|
</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: "var(--mono)"}}>
|
|
<div>NAME / SKU</div><div>BRAND</div><div style={{textAlign: "right"}}>THC%</div><div style={{textAlign: "right"}}>$</div><div style={{textAlign: "right"}}>REM g</div><div>BIN</div>
|
|
</div>
|
|
{data.products.filter(p=>p.status==="active").slice(0, 8).map(p => {
|
|
const bin = data.bins.find(b => b.id === p.binId);
|
|
return (
|
|
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 12, cursor: "pointer", alignItems: "center"}}>
|
|
<div>
|
|
<div style={{fontWeight: 500}}>{p.name}</div>
|
|
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)"}}>{p.sku}</div>
|
|
</div>
|
|
<div style={{color: "var(--ink-2)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)}</div>
|
|
<div className="mono" style={{textAlign: "right"}}>{p.thc.toFixed(1)}</div>
|
|
<div className="mono" style={{textAlign: "right"}}>{fmt.money(p.price)}</div>
|
|
<div className="mono" style={{textAlign: "right"}}>{window.remainingShort(p)}</div>
|
|
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{bin?.name || "—"}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Variation 3: minimal / editorial-quiet
|
|
const DashboardMinimal = ({data, stats, onNav, onSelectProduct}) => {
|
|
return (
|
|
<div style={{padding: "80px 60px", maxWidth: 900, margin: "0 auto"}}>
|
|
<div style={{textAlign: "center", marginBottom: 80}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 20}}>Saturday · April 25, 2026</div>
|
|
<div className="serif" style={{fontSize: 96, fontWeight: 400, letterSpacing: "-0.03em", lineHeight: 1, fontStyle: "italic"}}>
|
|
{stats.dailyAvg.toFixed(2)}<span style={{fontSize: 36, fontStyle: "normal", color: "var(--ink-3)", marginLeft: 8}}>g/day</span>
|
|
</div>
|
|
<div style={{marginTop: 20, fontSize: 14, color: "var(--ink-2)", maxWidth: 520, margin: "20px auto 0"}}>
|
|
{stats.activeCount} items in your cabinet, valued around {fmt.moneyShort(stats.inventoryValue)}, with roughly {Math.round(stats.daysOfSupply)} days of flower at this pace.
|
|
</div>
|
|
<div style={{marginTop: 8, fontSize: 13, color: "var(--ink-3)", maxWidth: 520, margin: "8px auto 0"}}>
|
|
{stats.consumedCount} items archived · avg lifespan {Math.round(stats.avgLifespan)} days.
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0, marginBottom: 80}}>
|
|
{[
|
|
["Avg $ per gram", fmt.money(stats.avgPerGram)],
|
|
["30-day spend", fmt.moneyShort(stats.spend30)],
|
|
["7-day THC", `${stats.thcLast7} mg`]
|
|
].map(([l,v], i) => (
|
|
<div key={l} style={{textAlign: "center", padding: "0 20px", borderRight: i < 2 ? "1px solid var(--line)" : "none"}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 10}}>{l}</div>
|
|
<div className="serif" style={{fontSize: 36, fontWeight: 500}}>{v}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{borderTop: "1px solid var(--line)", paddingTop: 40}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Recent additions</div>
|
|
<button onClick={() => onNav("inventory")} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>See all →</button>
|
|
</div>
|
|
{[...data.products].filter(p=>p.status==="active").sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
|
|
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "20px 0", borderBottom: "1px solid var(--line)", cursor: "pointer"}}>
|
|
<div>
|
|
<div className="serif" style={{fontSize: 24, fontWeight: 500}}>{p.name}</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)} · {fmt.dateShort(p.purchaseDate)}</div>
|
|
</div>
|
|
<div className="mono" style={{fontSize: 13, color: "var(--ink-2)"}}>{window.remainingShort(p)} · {fmt.money(p.price)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{marginTop: 60, display: "flex", gap: 12, justifyContent: "center"}}>
|
|
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Add product</Btn>
|
|
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|