Files
Apothecary/web/src/components/ShopDetail.tsx
T
josh 538e5079ab
Build and push image / build (push) Successful in 54s
Fix 18 UX issues: confirmations, undo, drawer nav, empty states, and polish
Comprehensive UX audit covering modals, drawers, dashboard, and inventory.
Key changes: confirmation steps before destructive actions, undo via toast
for consume/gone/checkout, back-navigation across entity drawers, optional
ratings, discrete item count field, audit progress bar, and sortable column
affordance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 16:25:41 -04:00

347 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from "react";
import type { Bootstrap, Shop, Brand, Product, Item } from "../types.js";
import { helpers, enrichItems } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
import { remainingShort } from "../stats.js";
import { useExitAnimation } from "../hooks/useExitAnimation.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
export function ShopDetail({
shop,
data,
onClose,
onEdit,
onDelete,
onSelectBrand,
onSelectItem,
}: {
shop: Shop;
data: Bootstrap;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onSelectBrand: (b: Brand) => void;
onSelectItem: (i: Item) => void;
}) {
const allItems = enrichItems(data).filter((i) => i.shopId === shop.id);
const hasItems = allItems.length > 0;
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
const consumed = allItems.filter((i) => i.status === "consumed");
const gone = allItems.filter((i) => i.status === "gone");
const totalSpend = allItems.reduce((s, i) => s + i.price, 0);
const avgPrice = hasItems ? totalSpend / allItems.length : 0;
const rated = allItems.filter((i) => i.rating != null);
const avgRating =
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
const brandIds = [...new Set(allItems.map((i) => i.brandId).filter(Boolean))] as string[];
const brands = brandIds
.map((id) => data.brands.find((b) => b.id === id))
.filter(Boolean) as Brand[];
const sortedItems = [...allItems].sort(
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
);
const recentItems = sortedItems.slice(0, 20);
const todayStr = getToday(getStoredTimezone());
const tz = getStoredTimezone();
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") triggerClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [triggerClose]);
const statCards: [string, React.ReactNode][] = [
["Purchases", String(allItems.length)],
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
];
return (
<div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "flex-end",
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}}
onClick={triggerClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100vw)",
height: "100%",
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)",
borderLeft: "1px solid var(--line)",
overflow: "auto",
boxShadow: "var(--shadow-lg)",
}}
>
<div
style={{
padding: "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "sticky",
top: 0,
background: "var(--bg)",
zIndex: 1,
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Shop
</div>
<div style={{ display: "flex", gap: 6 }}>
<Btn variant="ghost" icon="edit" onClick={onEdit} />
<Btn
variant="ghost"
icon="bin"
disabled={hasItems}
onClick={onDelete}
title={hasItems ? "Cannot delete — has inventory items" : undefined}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/>
<Btn variant="ghost" icon="close" onClick={triggerClose} />
</div>
</div>
<div style={{ padding: "32px 32px 60px" }}>
<h1
className="serif"
style={{
fontSize: 48,
margin: "0 0 4px",
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{shop.name}
</h1>
{shop.location && (
<div style={{ fontSize: 16, color: "var(--ink-2)", marginTop: 4 }}>
{shop.location}
</div>
)}
{hasItems && (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
gap: 1,
marginTop: 32,
background: "var(--line)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{statCards.map(([l, v], i) => (
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>
{v}
</div>
</div>
))}
</div>
)}
{hasItems && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Lifecycle
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{active.length > 0 && <Pill tone="sage">{active.length} active</Pill>}
{consumed.length > 0 && <Pill tone="terra">{consumed.length} consumed</Pill>}
{gone.length > 0 && <Pill tone="amber">{gone.length} gone</Pill>}
</div>
</div>
)}
{avgRating != null && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Ratings
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ display: "flex", gap: 2 }}>
{[1, 2, 3, 4, 5].map((n) => (
<Icon
key={n}
name="star"
size={18}
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
/>
))}
</div>
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
{avgRating.toFixed(1)}
</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
from {rated.length} review{rated.length === 1 ? "" : "s"}
</span>
</div>
</div>
)}
{brands.length > 0 && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Brands ({brands.length})
</div>
<div
style={{
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{brands.map((b, idx) => {
const brandItemCount = allItems.filter((i) => i.brandId === b.id).length;
return (
<div
key={b.id}
onClick={() => onSelectBrand(b)}
className="inv-row"
style={{
padding: "12px 16px",
borderBottom: idx < brands.length - 1 ? "1px solid var(--line)" : "none",
display: "grid",
gridTemplateColumns: "1fr auto auto",
alignItems: "center",
gap: 12,
background: "var(--surface)",
cursor: "pointer",
}}
>
<div style={{ fontWeight: 500, fontSize: 13 }}>{b.name}</div>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{brandItemCount} item{brandItemCount === 1 ? "" : "s"}
</span>
<span
className="inv-row-chevron"
style={{ color: "var(--ink-3)", fontSize: 14 }}
>
</span>
</div>
);
})}
</div>
</div>
)}
{hasItems && (
<div style={{ marginTop: 28 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
Recent purchases ({allItems.length})
</div>
<div
style={{
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
overflow: "hidden",
}}
>
{recentItems.map((item, idx) => {
const isInactive = item.status !== "active" && item.status !== "checked-out";
return (
<div
key={item.id}
onClick={() => onSelectItem(item)}
className="inv-row"
style={{
padding: "12px 16px",
borderBottom: idx < recentItems.length - 1 ? "1px solid var(--line)" : "none",
display: "grid",
gridTemplateColumns: "auto 1fr auto auto auto",
alignItems: "center",
gap: 12,
background: "var(--surface)",
cursor: "pointer",
opacity: isInactive ? 0.55 : 1,
}}
>
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
<span style={{ fontSize: 13 }}>
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
)}
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
)}
</span>
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{fmt.dateShort(item.purchaseDate, tz)}
</span>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{(item.status === "active" || item.status === "checked-out")
? remainingShort(item)
: ""}
</span>
</div>
);
})}
</div>
</div>
)}
{!hasItems && (
<div
style={{
marginTop: 36,
padding: 40,
textAlign: "center",
color: "var(--ink-3)",
background: "var(--bg-2)",
borderRadius: "var(--r-md)",
border: "1px solid var(--line)",
}}
>
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
<div style={{ fontSize: 12 }}>
Purchases from this shop will appear here.
</div>
</div>
)}
{hasItems && (
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
Cannot delete this shop while it has associated inventory items.
</div>
)}
</div>
</div>
</div>
);
}