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 { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
import { useToast } from "../components/Toast.js"; import { useToast } from "../components/Toast.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
// Bins follow a "letter + number" naming convention (A1, A2, B1, …). // Bins follow a "letter + number" naming convention (A1, A2, B1, …).
// Group by the letter prefix so each letter starts a new visual row, // Group by the letter prefix so each letter starts a new visual row,
@@ -50,6 +51,7 @@ export function BinsView({
onAddBin: () => void; onAddBin: () => void;
onEditBin: (bin: Bin) => void; onEditBin: (bin: Bin) => void;
}) { }) {
const isMobile = useIsMobile();
const qc = useQueryClient(); const qc = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const items = useMemo(() => enrichItems(data), [data]); const items = useMemo(() => enrichItems(data), [data]);
@@ -70,17 +72,24 @@ export function BinsView({
return ( return (
<div <div
style={{ 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, maxWidth: 2400,
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: isMobile ? 16 : 24 }}>
<div> <div>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>{data.bins.length} bins</div>
<h1 <h1
className="serif" 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 Bins & storage
</h1> </h1>
+138 -5
View File
@@ -3,6 +3,7 @@ import type { Bootstrap, Brand } from "../types.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
import { getStoredTimezone } from "../tz.js"; import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js"; import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
interface BrandRow { interface BrandRow {
brand: Brand; brand: Brand;
@@ -67,6 +68,7 @@ export function BrandsView({
onSelectBrand: (brand: Brand) => void; onSelectBrand: (brand: Brand) => void;
onAddBrand: () => void; onAddBrand: () => void;
}) { }) {
const isMobile = useIsMobile();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name"); const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -98,7 +100,9 @@ export function BrandsView({
return ( return (
<div <div
style={{ 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, maxWidth: 2400,
margin: "0 auto", margin: "0 auto",
}} }}
@@ -108,7 +112,7 @@ export function BrandsView({
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginBottom: 24, marginBottom: isMobile ? 16 : 24,
}} }}
> >
<div> <div>
@@ -117,7 +121,12 @@ export function BrandsView({
</div> </div>
<h1 <h1
className="serif" 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 Brands
</h1> </h1>
@@ -137,6 +146,72 @@ export function BrandsView({
Add your first brand Add your first brand
</Btn> </Btn>
</Card> </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 }}> <Card style={{ marginBottom: 14, padding: 14 }}>
@@ -185,7 +260,6 @@ export function BrandsView({
</button> </button>
)} )}
</div> </div>
<Select <Select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)} onChange={(e) => setSortBy(e.target.value as SortKey)}
@@ -200,7 +274,6 @@ export function BrandsView({
</Select> </Select>
</div> </div>
</Card> </Card>
<Card padded={false}> <Card padded={false}>
<BrandHeaderRow sortBy={sortBy} onSort={setSortBy} /> <BrandHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && ( {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 }) { function BrandItemRow({ row, onClick }: { row: BrandRow; onClick: () => void }) {
return ( return (
<div <div
+18 -5
View File
@@ -5,12 +5,14 @@ import type { Stats } from "../stats.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
import { BarChart, Card, Stat, Icon } from "../components/primitives/index.js"; import { BarChart, Card, Stat, Icon } from "../components/primitives/index.js";
import { getStoredTimezone } from "../tz.js"; import { getStoredTimezone } from "../tz.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
const DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DOW_FULL = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"]; const DOW_FULL = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Mon→Sun const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Mon→Sun
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
const isMobile = useIsMobile();
const tz = getStoredTimezone(); const tz = getStoredTimezone();
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams })); 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 ( return (
<div <div
style={{ 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, maxWidth: 2400,
margin: "0 auto", margin: "0 auto",
}} }}
> >
{/* ── 1. Header ──────────────────────────────────────────────── */} {/* ── 1. Header ──────────────────────────────────────────────── */}
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Last 90 days Last 90 days
</div> </div>
<h1 <h1
className="serif" 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 Patterns
</h1> </h1>
@@ -168,7 +177,9 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gridTemplateColumns: isMobile
? "1fr 1fr"
: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 14, gap: 14,
marginBottom: 14, marginBottom: 14,
}} }}
@@ -293,7 +304,9 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", gridTemplateColumns: isMobile
? "1fr"
: "repeat(auto-fit, minmax(340px, 1fr))",
gap: 14, gap: 14,
marginBottom: 14, marginBottom: 14,
}} }}
+79 -3
View File
@@ -5,6 +5,7 @@ import { getStoredTimezone } from "../tz.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Card, Icon } from "../components/primitives/index.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"; const GRID_COLS = "32px 2fr 1fr 1fr 1fr 280px";
@@ -21,6 +22,7 @@ export function CustodyView({
onConsume: (i: Item) => void; onConsume: (i: Item) => void;
onMarkGone: (i: Item) => void; onMarkGone: (i: Item) => void;
}) { }) {
const isMobile = useIsMobile();
const items = useMemo(() => enrichItems(data), [data]); const items = useMemo(() => enrichItems(data), [data]);
const checkedOut = useMemo( const checkedOut = useMemo(
() => () =>
@@ -37,18 +39,25 @@ export function CustodyView({
return ( return (
<div <div
style={{ 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, maxWidth: 2400,
margin: "0 auto", margin: "0 auto",
}} }}
> >
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
{checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out {checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out
</div> </div>
<h1 <h1
className="serif" 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 My Custody
</h1> </h1>
@@ -73,6 +82,20 @@ export function CustodyView({
Nothing checked out right now. Nothing checked out right now.
</div> </div>
</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}> <Card padded={false}>
<div <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({ function CustodyRow({
item, item,
data, data,
+12 -3
View File
@@ -1,6 +1,7 @@
import type { Bootstrap } from "../types.js"; import type { Bootstrap } from "../types.js";
import { Btn, Card, Select, Stat } from "../components/primitives/index.js"; import { Btn, Card, Select, Stat } from "../components/primitives/index.js";
import { getBrowserTimezone } from "../tz.js"; import { getBrowserTimezone } from "../tz.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
function getTimezoneOptions(): string[] { function getTimezoneOptions(): string[] {
try { try {
@@ -98,19 +99,27 @@ export function SettingsView({
timezone: string; timezone: string;
onTimezoneChange: (tz: string) => void; onTimezoneChange: (tz: string) => void;
}) { }) {
const isMobile = useIsMobile();
return ( return (
<div <div
style={{ 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, maxWidth: 1400,
margin: "0 auto", margin: "0 auto",
}} }}
> >
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</div> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>Settings</div>
<h1 <h1
className="serif" 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 Preferences
</h1> </h1>
+141 -5
View File
@@ -3,6 +3,7 @@ import type { Bootstrap, Shop } from "../types.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
import { getStoredTimezone } from "../tz.js"; import { getStoredTimezone } from "../tz.js";
import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js"; import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js";
import { useIsMobile } from "../hooks/useIsMobile.js";
interface ShopRow { interface ShopRow {
shop: Shop; shop: Shop;
@@ -57,6 +58,7 @@ export function ShopsView({
onSelectShop: (shop: Shop) => void; onSelectShop: (shop: Shop) => void;
onAddShop: () => void; onAddShop: () => void;
}) { }) {
const isMobile = useIsMobile();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name"); const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -91,7 +93,9 @@ export function ShopsView({
return ( return (
<div <div
style={{ 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, maxWidth: 2400,
margin: "0 auto", margin: "0 auto",
}} }}
@@ -101,7 +105,7 @@ export function ShopsView({
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginBottom: 24, marginBottom: isMobile ? 16 : 24,
}} }}
> >
<div> <div>
@@ -110,7 +114,12 @@ export function ShopsView({
</div> </div>
<h1 <h1
className="serif" 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 Shops
</h1> </h1>
@@ -130,6 +139,71 @@ export function ShopsView({
Add your first shop Add your first shop
</Btn> </Btn>
</Card> </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 }}> <Card style={{ marginBottom: 14, padding: 14 }}>
@@ -178,7 +252,6 @@ export function ShopsView({
</button> </button>
)} )}
</div> </div>
<Select <Select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)} onChange={(e) => setSortBy(e.target.value as SortKey)}
@@ -192,7 +265,6 @@ export function ShopsView({
</Select> </Select>
</div> </div>
</Card> </Card>
<Card padded={false}> <Card padded={false}>
<ShopHeaderRow sortBy={sortBy} onSort={setSortBy} /> <ShopHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && ( {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 }) { function ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) {
return ( return (
<div <div
+206 -49
View File
@@ -3,7 +3,8 @@ import type { Bootstrap, Product } from "../types.js";
import { TYPES, helpers } from "../types.js"; import { TYPES, helpers } from "../types.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { getStoredTimezone } from "../tz.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 { export interface SkuRow {
product: Product; product: Product;
@@ -73,6 +74,7 @@ export function SkusView({
onSelectSku: (p: Product) => void; onSelectSku: (p: Product) => void;
onAddSku: () => void; onAddSku: () => void;
}) { }) {
const isMobile = useIsMobile();
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<SortKey>("name"); const [sortBy, setSortBy] = useState<SortKey>("name");
@@ -113,7 +115,9 @@ export function SkusView({
return ( return (
<div <div
style={{ 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, maxWidth: 2400,
margin: "0 auto", margin: "0 auto",
}} }}
@@ -123,7 +127,7 @@ export function SkusView({
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginBottom: 24, marginBottom: isMobile ? 16 : 24,
}} }}
> >
<div> <div>
@@ -132,7 +136,12 @@ export function SkusView({
</div> </div>
<h1 <h1
className="serif" 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 SKUs
</h1> </h1>
@@ -142,19 +151,18 @@ export function SkusView({
</Btn> </Btn>
</div> </div>
<Card style={{ marginBottom: 14, padding: 14 }}> {isMobile ? (
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}> <>
<div <div
style={{ style={{
flex: 1,
minWidth: 220,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
background: "var(--bg-2)", background: "var(--surface)",
border: "1px solid var(--line)", border: "1px solid var(--line)",
borderRadius: "var(--r-md)", borderRadius: "var(--r-md)",
padding: "0 10px", padding: "0 10px",
marginBottom: 10,
}} }}
> >
<Icon name="search" size={14} color="var(--ink-3)" /> <Icon name="search" size={14} color="var(--ink-3)" />
@@ -166,8 +174,8 @@ export function SkusView({
border: "none", border: "none",
outline: "none", outline: "none",
background: "transparent", background: "transparent",
padding: "8px 0", padding: "10px 0",
fontSize: 13, fontSize: 14,
flex: 1, flex: 1,
color: "var(--ink)", color: "var(--ink)",
}} }}
@@ -188,45 +196,122 @@ export function SkusView({
</button> </button>
)} )}
</div> </div>
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
<Select <Select
value={typeFilter} value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)} onChange={(e) => setTypeFilter(e.target.value)}
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }} style={{ ...inputStyle, flex: 1, padding: "8px 10px" }}
> >
<option value="all">All types</option> <option value="all">All types</option>
{TYPES.map((t) => ( {TYPES.map((t) => (
<option key={t.id} value={t.id}> <option key={t.id} value={t.id}>{t.id}</option>
{t.id} ))}
</option> </Select>
))} <Select
</Select> value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
<Select style={{ ...inputStyle, flex: 1, padding: "8px 10px" }}
value={sortBy} >
onChange={(e) => setSortBy(e.target.value as SortKey)} <option value="name">Name (A-Z)</option>
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }} <option value="items">Most items</option>
> <option value="spent">Most spent</option>
<option value="name">Name (A-Z)</option> <option value="recent">Recent</option>
<option value="items">Most items</option> <option value="rating">Top rated</option>
<option value="spent">Most spent</option> </Select>
<option value="recent">Recent purchase</option>
<option value="rating">Highest rated</option>
</Select>
</div>
</Card>
<Card padded={false}>
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No SKUs match these filters.
</div> </div>
)} {sorted.length === 0 && (
{sorted.map((r) => ( <div style={{ padding: 40, textAlign: "center", color: "var(--ink-3)", fontSize: 13 }}>
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} /> No SKUs match these filters.
))} </div>
</Card> )}
{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
style={{
flex: 1,
minWidth: 220,
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 by name, SKU, brand..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
border: "none",
outline: "none",
background: "transparent",
padding: "8px 0",
fontSize: 13,
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={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
style={{ ...inputStyle, width: "auto", 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, width: "auto", 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 purchase</option>
<option value="rating">Highest rated</option>
</Select>
</div>
</Card>
<Card padded={false}>
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
{sorted.length === 0 && (
<div style={{ padding: 60, textAlign: "center", color: "var(--ink-3)" }}>
No SKUs match these filters.
</div>
)}
{sorted.map((r) => (
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
))}
</Card>
</>
)}
</div> </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 }) { function SkuItemRow({ row, onClick }: { row: SkuRow; onClick: () => void }) {
return ( return (
<div <div