Add mobile card layouts to SKUs, Brands, Shops, Custody, and remaining views
Build and push image / build (push) Successful in 59s

All list views now render card-based layouts on mobile instead of
multi-column grid tables that overflow on small screens. Also adjust
container padding, heading sizes, and grid breakpoints for Bins,
Patterns, and Settings views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:37:37 -04:00
parent c9094d39ec
commit ddaeea0223
7 changed files with 606 additions and 73 deletions
+12 -3
View File
@@ -8,6 +8,7 @@ import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
import { useToast } from "../components/Toast.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
// Group by the letter prefix so each letter starts a new visual row,
@@ -50,6 +51,7 @@ export function BinsView({
onAddBin: () => void;
onEditBin: (bin: Bin) => void;
}) {
const isMobile = useIsMobile();
const qc = useQueryClient();
const { toast } = useToast();
const items = useMemo(() => enrichItems(data), [data]);
@@ -70,17 +72,24 @@ export function BinsView({
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={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: isMobile ? 16 : 24 }}>
<div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</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",
}}
>
Bins & storage
</h1>
+138 -5
View File
@@ -3,6 +3,7 @@ import type { Bootstrap, Brand } from "../types.js";
import { fmt } from "../format.js";
import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
interface BrandRow {
brand: Brand;
@@ -67,6 +68,7 @@ export function BrandsView({
onSelectBrand: (brand: Brand) => void;
onAddBrand: () => void;
}) {
const isMobile = useIsMobile();
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -98,7 +100,9 @@ export function BrandsView({
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",
}}
@@ -108,7 +112,7 @@ export function BrandsView({
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 24,
marginBottom: isMobile ? 16 : 24,
}}
>
<div>
@@ -117,7 +121,12 @@ export function BrandsView({
</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",
}}
>
Brands
</h1>
@@ -137,6 +146,72 @@ export function BrandsView({
Add your first brand
</Btn>
</Card>
) : isMobile ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "0 10px",
marginBottom: 10,
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by brand name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "10px 0",
fontSize: 14,
flex: 1,
color: "var(--ink)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</div>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
style={{ ...inputStyle, width: "100%", padding: "8px 10px", marginBottom: 14 }}
>
<option value="name">Name (A-Z)</option>
<option value="skus">Most SKUs</option>
<option value="items">Most items</option>
<option value="spent">Most spent</option>
<option value="recent">Recent</option>
<option value="rating">Top rated</option>
</Select>
{sorted.length === 0 && (
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
No brands match these filters.
</div>
)}
{sorted.map((r) => (
<MobileBrandCard key={r.brand.id} row={r} onClick={() => onSelectBrand(r.brand)} />
))}
</>
) : (
<>
<Card style={{ marginBottom: 14, padding: 14 }}>
@@ -185,7 +260,6 @@ export function BrandsView({
</button>
)}
</div>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
@@ -200,7 +274,6 @@ export function BrandsView({
</Select>
</div>
</Card>
<Card padded={false}>
<BrandHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
@@ -276,6 +349,66 @@ function BrandHeaderRow({
);
}
function MobileBrandCard({ row, onClick }: { row: BrandRow; onClick: () => void }) {
return (
<div
onClick={onClick}
style={{
padding: "14px 16px",
background: "var(--surface)",
borderRadius: "var(--r-md)",
marginBottom: 8,
cursor: "pointer",
border: "1px solid var(--line)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 14,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.brand.name}
</div>
</div>
<span style={{ color: "var(--ink-3)", fontSize: 16 }}></span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid var(--line)",
fontSize: 12,
color: "var(--ink-2)",
}}
>
<span className="mono">{row.skuCount} SKU{row.skuCount !== 1 ? "s" : ""}</span>
<span className="mono">{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}</span>
{row.itemCount > 0 && <span className="mono">{fmt.money(row.totalSpend)}</span>}
{row.avgRating != null && (
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
<Icon name="star" size={11} color="var(--amber)" />
<span className="mono">{row.avgRating.toFixed(1)}</span>
</span>
)}
{row.lastPurchase && (
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
</span>
)}
</div>
</div>
);
}
function BrandItemRow({ row, onClick }: { row: BrandRow; onClick: () => void }) {
return (
<div
+18 -5
View File
@@ -5,12 +5,14 @@ import type { Stats } from "../stats.js";
import { fmt } from "../format.js";
import { BarChart, Card, Stat, Icon } from "../components/primitives/index.js";
import { getStoredTimezone } from "../tz.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
const DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DOW_FULL = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Mon→Sun
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
const isMobile = useIsMobile();
const tz = getStoredTimezone();
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
@@ -128,19 +130,26 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
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",
}}
>
{/* ── 1. Header ──────────────────────────────────────────────── */}
<div style={{ marginBottom: 24 }}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Last 90 days
</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",
}}
>
Patterns
</h1>
@@ -168,7 +177,9 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gridTemplateColumns: isMobile
? "1fr 1fr"
: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 14,
marginBottom: 14,
}}
@@ -293,7 +304,9 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
gridTemplateColumns: isMobile
? "1fr"
: "repeat(auto-fit, minmax(340px, 1fr))",
gap: 14,
marginBottom: 14,
}}
+79 -3
View File
@@ -5,6 +5,7 @@ import { getStoredTimezone } from "../tz.js";
import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Card, Icon } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
const GRID_COLS = "32px 2fr 1fr 1fr 1fr 280px";
@@ -21,6 +22,7 @@ export function CustodyView({
onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void;
}) {
const isMobile = useIsMobile();
const items = useMemo(() => enrichItems(data), [data]);
const checkedOut = useMemo(
() =>
@@ -37,18 +39,25 @@ export function CustodyView({
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)" }}>
{checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out
</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",
}}
>
My Custody
</h1>
@@ -73,6 +82,20 @@ export function CustodyView({
Nothing checked out right now.
</div>
</div>
) : isMobile ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{checkedOut.map((item) => (
<MobileCustodyCard
key={item.id}
item={item}
data={data}
onSelect={() => onSelectItem(item)}
onCheckin={() => onCheckin(item)}
onConsume={() => onConsume(item)}
onMarkGone={() => onMarkGone(item)}
/>
))}
</div>
) : (
<Card padded={false}>
<div
@@ -112,6 +135,59 @@ export function CustodyView({
);
}
function MobileCustodyCard({
item,
data,
onSelect,
onCheckin,
onConsume,
onMarkGone,
}: {
item: Item;
data: Bootstrap;
onSelect: () => void;
onCheckin: () => void;
onConsume: () => void;
onMarkGone: () => void;
}) {
const glyph = TYPE_GLYPHS[item.type] ?? "·";
const pct = helpers.pctRemaining(item);
return (
<div
onClick={onSelect}
style={{
padding: "14px 16px",
background: "var(--surface)",
borderRadius: "var(--r-md)",
cursor: "pointer",
border: "1px solid var(--line)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 18, opacity: 0.6 }}>{glyph}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>{item.name}</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
{helpers.brandName(data, item.brandId)} · {remainingShort(item)} · {Math.round(pct * 100)}%
</div>
</div>
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)", marginTop: 6 }}>
Checked out {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
</div>
<div
style={{ display: "flex", gap: 6, marginTop: 10 }}
onClick={(e) => e.stopPropagation()}
>
<Btn variant="sage" icon="check" onClick={onCheckin}>Check in</Btn>
<Btn variant="secondary" icon="check" onClick={onConsume}>Consume</Btn>
<Btn variant="ghost" icon="bin" onClick={onMarkGone} />
</div>
</div>
);
}
function CustodyRow({
item,
data,
+12 -3
View File
@@ -1,6 +1,7 @@
import type { Bootstrap } from "../types.js";
import { Btn, Card, Select, Stat } from "../components/primitives/index.js";
import { getBrowserTimezone } from "../tz.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
function getTimezoneOptions(): string[] {
try {
@@ -98,19 +99,27 @@ export function SettingsView({
timezone: string;
onTimezoneChange: (tz: string) => void;
}) {
const isMobile = useIsMobile();
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: 1400,
margin: "0 auto",
}}
>
<div style={{ marginBottom: 24 }}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</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",
}}
>
Preferences
</h1>
+141 -5
View File
@@ -3,6 +3,7 @@ import type { Bootstrap, Shop } from "../types.js";
import { fmt } from "../format.js";
import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
interface ShopRow {
shop: Shop;
@@ -57,6 +58,7 @@ export function ShopsView({
onSelectShop: (shop: Shop) => void;
onAddShop: () => void;
}) {
const isMobile = useIsMobile();
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -91,7 +93,9 @@ export function ShopsView({
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",
}}
@@ -101,7 +105,7 @@ export function ShopsView({
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 24,
marginBottom: isMobile ? 16 : 24,
}}
>
<div>
@@ -110,7 +114,12 @@ export function ShopsView({
</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",
}}
>
Shops
</h1>
@@ -130,6 +139,71 @@ export function ShopsView({
Add your first shop
</Btn>
</Card>
) : isMobile ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "0 10px",
marginBottom: 10,
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by name or location..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "10px 0",
fontSize: 14,
flex: 1,
color: "var(--ink)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</div>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
style={{ ...inputStyle, width: "100%", padding: "8px 10px", marginBottom: 14 }}
>
<option value="name">Name (A-Z)</option>
<option value="items">Most items</option>
<option value="spent">Most spent</option>
<option value="recent">Recent</option>
<option value="rating">Top rated</option>
</Select>
{sorted.length === 0 && (
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
No shops match these filters.
</div>
)}
{sorted.map((r) => (
<MobileShopCard key={r.shop.id} row={r} onClick={() => onSelectShop(r.shop)} />
))}
</>
) : (
<>
<Card style={{ marginBottom: 14, padding: 14 }}>
@@ -178,7 +252,6 @@ export function ShopsView({
</button>
)}
</div>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
@@ -192,7 +265,6 @@ export function ShopsView({
</Select>
</div>
</Card>
<Card padded={false}>
<ShopHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
@@ -268,6 +340,70 @@ function ShopHeaderRow({
);
}
function MobileShopCard({ row, onClick }: { row: ShopRow; onClick: () => void }) {
return (
<div
onClick={onClick}
style={{
padding: "14px 16px",
background: "var(--surface)",
borderRadius: "var(--r-md)",
marginBottom: 8,
cursor: "pointer",
border: "1px solid var(--line)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 14,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.shop.name}
</div>
{row.shop.location && (
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
{row.shop.location}
</div>
)}
</div>
<span style={{ color: "var(--ink-3)", fontSize: 16 }}></span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid var(--line)",
fontSize: 12,
color: "var(--ink-2)",
}}
>
<span className="mono">{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}</span>
{row.itemCount > 0 && <span className="mono">{fmt.money(row.totalSpend)}</span>}
{row.avgRating != null && (
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
<Icon name="star" size={11} color="var(--amber)" />
<span className="mono">{row.avgRating.toFixed(1)}</span>
</span>
)}
{row.lastPurchase && (
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
</span>
)}
</div>
</div>
);
}
function ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) {
return (
<div
+167 -10
View File
@@ -3,7 +3,8 @@ import type { Bootstrap, Product } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
import { Btn, Card, Icon, Pill, Select, inputStyle } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
export interface SkuRow {
product: Product;
@@ -73,6 +74,7 @@ export function SkusView({
onSelectSku: (p: Product) => void;
onAddSku: () => void;
}) {
const isMobile = useIsMobile();
const [typeFilter, setTypeFilter] = useState("all");
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -113,7 +115,9 @@ export function SkusView({
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",
}}
@@ -123,7 +127,7 @@ export function SkusView({
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 24,
marginBottom: isMobile ? 16 : 24,
}}
>
<div>
@@ -132,7 +136,12 @@ export function SkusView({
</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",
}}
>
SKUs
</h1>
@@ -142,6 +151,85 @@ export function SkusView({
</Btn>
</div>
{isMobile ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "0 10px",
marginBottom: 10,
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by name, SKU, brand..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "10px 0",
fontSize: 14,
flex: 1,
color: "var(--ink)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
}}
>
<Icon name="close" size={12} />
</button>
)}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
style={{ ...inputStyle, flex: 1, padding: "8px 10px" }}
>
<option value="all">All types</option>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>{t.id}</option>
))}
</Select>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
style={{ ...inputStyle, flex: 1, padding: "8px 10px" }}
>
<option value="name">Name (A-Z)</option>
<option value="items">Most items</option>
<option value="spent">Most spent</option>
<option value="recent">Recent</option>
<option value="rating">Top rated</option>
</Select>
</div>
{sorted.length === 0 && (
<div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
No SKUs match these filters.
</div>
)}
{sorted.map((r) => (
<MobileSkuCard key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
))}
</>
) : (
<>
<Card style={{ marginBottom: 14, padding: 14 }}>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div
@@ -188,7 +276,6 @@ export function SkusView({
</button>
)}
</div>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
@@ -196,12 +283,9 @@ export function SkusView({
>
<option value="all">All types</option>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.id}
</option>
<option key={t.id} value={t.id}>{t.id}</option>
))}
</Select>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
@@ -215,7 +299,6 @@ export function SkusView({
</Select>
</div>
</Card>
<Card padded={false}>
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
@@ -227,6 +310,8 @@ export function SkusView({
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
))}
</Card>
</>
)}
</div>
);
}
@@ -289,6 +374,78 @@ function SkuHeaderRow({
);
}
function MobileSkuCard({ row, onClick }: { row: SkuRow; onClick: () => void }) {
return (
<div
onClick={onClick}
style={{
padding: "14px 16px",
background: "var(--surface)",
borderRadius: "var(--r-md)",
marginBottom: 8,
cursor: "pointer",
border: "1px solid var(--line)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--ink-3)", width: 24 }}>
{TYPE_GLYPHS[row.product.type]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 14,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 2 }}>
{row.brand} · {row.product.type}
</div>
</div>
<span style={{ color: "var(--ink-3)", fontSize: 16 }}></span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 10,
paddingTop: 10,
borderTop: "1px solid var(--line)",
fontSize: 12,
color: "var(--ink-2)",
}}
>
<span className="mono">
{row.itemCount} item{row.itemCount !== 1 ? "s" : ""}
{row.activeCount > 0 && row.activeCount < row.itemCount && (
<span style={{ color: "var(--ink-3)" }}> ({row.activeCount} active)</span>
)}
</span>
{row.itemCount > 0 && (
<span className="mono">{fmt.money(row.totalSpend)}</span>
)}
{row.avgRating != null && (
<span style={{ display: "flex", alignItems: "center", gap: 3 }}>
<Icon name="star" size={11} color="var(--amber)" />
<span className="mono">{row.avgRating.toFixed(1)}</span>
</span>
)}
{row.lastPurchase && (
<span style={{ marginLeft: "auto", color: "var(--ink-3)" }}>
{fmt.dateShort(row.lastPurchase, getStoredTimezone())}
</span>
)}
</div>
</div>
);
}
function SkuItemRow({ row, onClick }: { row: SkuRow; onClick: () => void }) {
return (
<div