Overhaul shops tab with table view, search/sort, and detail drawer
Build and push image / build (push) Successful in 48s

Replaces the card grid with a table matching the brands/SKU view patterns:
sortable columns (name, items, spend, rating, last purchase), search by
name or location, and a right-side detail drawer showing aggregate stats,
lifecycle pills, unique brands list, and recent inventory items with
cross-navigation to the brand and inventory detail drawers. Location
appears as a subtitle under the shop name in table rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:50:25 -04:00
parent 8f09504f26
commit 9e31a6ad00
3 changed files with 660 additions and 95 deletions
+36 -1
View File
@@ -19,6 +19,7 @@ import type { ThemeKey } from "./views/SettingsView.js";
import { ProductDetail } from "./components/ProductDetail.js"; import { ProductDetail } from "./components/ProductDetail.js";
import { SkuDetail } from "./components/SkuDetail.js"; import { SkuDetail } from "./components/SkuDetail.js";
import { BrandDetail } from "./components/BrandDetail.js"; import { BrandDetail } from "./components/BrandDetail.js";
import { ShopDetail } from "./components/ShopDetail.js";
import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js"; import { AddInventoryFlow } from "./components/modals/AddInventoryFlow.js";
import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js"; import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js"; import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
@@ -76,6 +77,7 @@ export function App() {
const [bulkItems, setBulkItems] = useState<Item[]>([]); const [bulkItems, setBulkItems] = useState<Item[]>([]);
const [selectedSku, setSelectedSku] = useState<Product | null>(null); const [selectedSku, setSelectedSku] = useState<Product | null>(null);
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null); const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null); const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [theme, setTheme] = useState<ThemeKey>( const [theme, setTheme] = useState<ThemeKey>(
@@ -124,6 +126,13 @@ export function App() {
} }
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps }, [data]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (selectedShop && data) {
const fresh = data.shops.find((s) => s.id === selectedShop.id);
if (fresh && fresh !== selectedShop) setSelectedShop(fresh);
}
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
const openAdd = () => { const openAdd = () => {
setModalItem(null); setModalItem(null);
setModal("add"); setModal("add");
@@ -263,7 +272,7 @@ export function App() {
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} /> <BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
} /> } />
<Route path="/shops" element={ <Route path="/shops" element={
<ShopsView data={data} onAddShop={() => setModal("addShop")} onEditShop={(shop) => { setModalShop(shop); setModal("editShop"); }} /> <ShopsView data={data} onSelectShop={setSelectedShop} onAddShop={() => setModal("addShop")} />
} /> } />
<Route path="/brands" element={ <Route path="/brands" element={
<BrandsView data={data} onSelectBrand={setSelectedBrand} onAddBrand={() => setModal("addBrand")} /> <BrandsView data={data} onSelectBrand={setSelectedBrand} onAddBrand={() => setModal("addBrand")} />
@@ -337,6 +346,32 @@ export function App() {
/> />
)} )}
{selectedShop && (
<ShopDetail
shop={selectedShop}
data={data}
onClose={() => setSelectedShop(null)}
onEdit={() => {
setModalShop(selectedShop);
setModal("editShop");
}}
onDelete={() => {
api.deleteShop(selectedShop.id).then(() => {
setSelectedShop(null);
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
});
}}
onSelectBrand={(b) => {
setSelectedShop(null);
setSelectedBrand(b);
}}
onSelectItem={(i) => {
setSelectedShop(null);
setSelected(i);
}}
/>
)}
{modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />} {modal === "add" && <AddInventoryFlow data={data} onClose={() => setModal(null)} />}
{modal === "edit" && modalItem && ( {modal === "edit" && modalItem && (
<EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} /> <EditInventoryFlow data={data} item={modalItem} onClose={() => setModal(null)} />
+337
View File
@@ -0,0 +1,337 @@
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";
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();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const statCards: [string, React.ReactNode][] = [
["Purchases", String(allItems.length)],
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
];
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100vw)",
height: "100%",
animation: "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}
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
/>
<Btn variant="ghost" icon="close" onClick={onClose} />
</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>
);
}
+287 -94
View File
@@ -1,29 +1,92 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Shop } from "../types.js"; import type { Bootstrap, Shop } from "../types.js";
import { api } from "../api.js"; import { fmt } from "../format.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; import { getStoredTimezone } from "../tz.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
interface ShopRow {
shop: Shop;
itemCount: number;
totalSpend: number;
avgRating: number | null;
ratingCount: number;
lastPurchase: string | null;
}
type SortKey = "name" | "items" | "spent" | "rating" | "recent";
const GRID_COLS = "2fr 0.7fr 0.8fr 0.8fr 1fr";
function buildShopRows(data: Bootstrap): ShopRow[] {
const itemsByShop = new Map<string, typeof data.inventoryItems>();
for (const i of data.inventoryItems) {
if (!i.shopId) continue;
const arr = itemsByShop.get(i.shopId);
if (arr) arr.push(i);
else itemsByShop.set(i.shopId, [i]);
}
return data.shops.map((shop) => {
const items = itemsByShop.get(shop.id) ?? [];
const totalSpend = items.reduce((s, i) => s + i.price, 0);
const rated = items.filter((i) => i.rating != null);
const avgRating =
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
const dates = items.map((i) => i.purchaseDate).sort();
const lastPurchase = dates.length > 0 ? dates[dates.length - 1]! : null;
return {
shop,
itemCount: items.length,
totalSpend,
avgRating,
ratingCount: rated.length,
lastPurchase,
};
});
}
export function ShopsView({ export function ShopsView({
data, data,
onSelectShop,
onAddShop, onAddShop,
onEditShop,
}: { }: {
data: Bootstrap; data: Bootstrap;
onSelectShop: (shop: Shop) => void;
onAddShop: () => void; onAddShop: () => void;
onEditShop: (shop: Shop) => void;
}) { }) {
const qc = useQueryClient(); const [search, setSearch] = useState("");
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); const [sortBy, setSortBy] = useState<SortKey>("name");
const remove = useMutation({ const rows = useMemo(() => buildShopRows(data), [data]);
mutationFn: (id: string) => api.deleteShop(id),
onSuccess: () => { const filtered = useMemo(() => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); if (!search) return rows;
setConfirmDelete(null); const q = search.toLowerCase();
}, return rows.filter(
}); (r) =>
r.shop.name.toLowerCase().includes(q) ||
(r.shop.location && r.shop.location.toLowerCase().includes(q)),
);
}, [rows, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortBy === "name") copy.sort((a, b) => a.shop.name.localeCompare(b.shop.name));
else if (sortBy === "items") copy.sort((a, b) => b.itemCount - a.itemCount);
else if (sortBy === "spent") copy.sort((a, b) => b.totalSpend - a.totalSpend);
else if (sortBy === "recent")
copy.sort(
(a, b) =>
+(b.lastPurchase ? new Date(b.lastPurchase) : 0) -
+(a.lastPurchase ? new Date(a.lastPurchase) : 0),
);
else if (sortBy === "rating")
copy.sort((a, b) => (b.avgRating ?? -1) - (a.avgRating ?? -1));
return copy;
}, [filtered, sortBy]);
return ( return (
<div <div
@@ -33,10 +96,17 @@ export function ShopsView({
margin: "0 auto", margin: "0 auto",
}} }}
> >
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}> <div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 24,
}}
>
<div> <div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{data.shops.length} shop{data.shops.length === 1 ? "" : "s"} {sorted.length} shop{sorted.length === 1 ? "" : "s"}
</div> </div>
<h1 <h1
className="serif" className="serif"
@@ -45,10 +115,9 @@ export function ShopsView({
Shops Shops
</h1> </h1>
</div> </div>
<Btn variant="primary" icon="plus" onClick={onAddShop}>New shop</Btn> <Btn variant="primary" icon="plus" onClick={onAddShop}>
</div> New shop
<div style={{ fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600 }}> </Btn>
Where you've purchased from. Used in the shop dropdown when adding a new product.
</div> </div>
{data.shops.length === 0 ? ( {data.shops.length === 0 ? (
@@ -57,86 +126,210 @@ export function ShopsView({
<div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}> <div style={{ fontSize: 13, color: "var(--ink-3)", marginBottom: 18 }}>
Add a shop to start logging where each purchase came from. Add a shop to start logging where each purchase came from.
</div> </div>
<Btn variant="primary" icon="plus" onClick={onAddShop}>Add your first shop</Btn> <Btn variant="primary" icon="plus" onClick={onAddShop}>
Add your first shop
</Btn>
</Card> </Card>
) : ( ) : (
<div <>
style={{ <Card style={{ marginBottom: 14, padding: 14 }}>
display: "grid", <div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", <div
gap: 14, style={{
}} flex: 1,
> minWidth: 220,
{data.shops.map((s) => { display: "flex",
const count = data.inventoryItems.filter((i) => i.shopId === s.id).length; alignItems: "center",
return ( gap: 8,
<Card key={s.id} style={{ display: "flex", alignItems: "center", gap: 12 }}> background: "var(--bg-2)",
<div style={{ flex: 1, minWidth: 0 }}> border: "1px solid var(--line)",
<div className="serif" style={{ fontSize: 22, fontWeight: 500, lineHeight: 1.1 }}> borderRadius: "var(--r-md)",
{s.name} padding: "0 10px",
</div> }}
{s.location && ( >
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}> <Icon name="search" size={14} color="var(--ink-3)" />
{s.location} <input
</div> placeholder="Search by name or location..."
)} value={search}
</div> onChange={(e) => setSearch(e.target.value)}
<Pill tone="outline">
{count} purchase{count === 1 ? "" : "s"}
</Pill>
<button
onClick={() => onEditShop(s)}
title="Edit shop"
aria-label={`Edit shop ${s.name}`}
style={{ style={{
background: "transparent",
border: "none", border: "none",
padding: 4, outline: "none",
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="edit" size={14} />
</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name, count })}
title="Remove shop"
aria-label={`Remove shop ${s.name}`}
disabled={remove.isPending}
style={{
background: "transparent", background: "transparent",
border: "none", padding: "8px 0",
padding: 4, fontSize: 13,
borderRadius: "var(--r-sm)", flex: 1,
cursor: remove.isPending ? "wait" : "pointer", color: "var(--ink)",
color: "var(--ink-3)",
display: "inline-flex",
}} }}
> />
<Icon name="bin" size={14} /> {search && (
</button> <button
</Card> onClick={() => setSearch("")}
); style={{
})} background: "transparent",
</div> border: "none",
)} cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</div>
{confirmDelete && ( <Select
<ConfirmDialog value={sortBy}
title={`Delete "${confirmDelete.name}"?`} onChange={(e) => setSortBy(e.target.value as SortKey)}
message={ style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
confirmDelete.count > 0 >
? `${confirmDelete.count} product${confirmDelete.count === 1 ? "" : "s"} will lose this shop.` <option value="name">Name (A-Z)</option>
: "This shop will be permanently removed." <option value="items">Most items</option>
} <option value="spent">Most spent</option>
confirmLabel="Delete shop" <option value="recent">Recent purchase</option>
onConfirm={() => remove.mutate(confirmDelete.id)} <option value="rating">Highest rated</option>
onCancel={() => setConfirmDelete(null)} </Select>
isPending={remove.isPending} </div>
/> </Card>
<Card padded={false}>
<ShopHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No shops match these filters.
</div>
)}
{sorted.map((r) => (
<ShopItemRow key={r.shop.id} row={r} onClick={() => onSelectShop(r.shop)} />
))}
</Card>
</>
)} )}
</div> </div>
); );
} }
const COL_SORT: (SortKey | null)[] = ["name", "items", "spent", "rating", "recent"];
const COL_LABELS = ["Name", "Items", "Spent", "Rating", "Last purchase"];
function ShopHeaderRow({
sortBy,
onSort,
}: {
sortBy: SortKey;
onSort: (k: SortKey) => void;
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: GRID_COLS,
columnGap: 16,
padding: "12px 20px",
borderBottom: "1px solid var(--line)",
background: "var(--bg-2)",
fontSize: 11,
color: "var(--ink-3)",
textTransform: "uppercase",
letterSpacing: "0.08em",
alignItems: "center",
}}
>
{COL_LABELS.map((label, i) => {
const sk = COL_SORT[i];
if (!sk) return <div key={i}>{label}</div>;
const active = sortBy === sk;
return (
<button
key={i}
onClick={() => onSort(sk)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: "inherit",
textTransform: "inherit",
letterSpacing: "inherit",
fontWeight: active ? 600 : "inherit",
color: active ? "var(--ink)" : "var(--ink-3)",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{label}
{active && <span style={{ fontSize: 9 }}></span>}
</button>
);
})}
</div>
);
}
function ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) {
return (
<div
onClick={onClick}
className="inv-row"
style={{
display: "grid",
gridTemplateColumns: GRID_COLS,
columnGap: 16,
padding: "14px 20px",
borderBottom: "1px solid var(--line)",
alignItems: "center",
cursor: "pointer",
fontSize: 13,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: "var(--ink)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.shop.name}
</div>
{row.shop.location && (
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{row.shop.location}
</div>
)}
</div>
<div style={{ fontFamily: "var(--mono)" }}>{row.itemCount}</div>
<div style={{ fontFamily: "var(--mono)" }}>
{row.itemCount > 0 ? fmt.money(row.totalSpend) : "—"}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{row.avgRating != null ? (
<>
<Icon name="star" size={12} color="var(--amber)" />
<span style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
{row.avgRating.toFixed(1)}
</span>
<span style={{ fontSize: 10, color: "var(--ink-3)" }}>({row.ratingCount})</span>
</>
) : (
<span style={{ color: "var(--ink-3)", fontSize: 12, fontStyle: "italic" }}></span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
{row.lastPurchase ? fmt.dateShort(row.lastPurchase, getStoredTimezone()) : "—"}
</span>
<span
className="inv-row-chevron"
style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}
>
</span>
</div>
</div>
);
}