Add mobile card layouts to SKUs, Brands, Shops, Custody, and remaining views
Build and push image / build (push) Successful in 59s
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+206
-49
@@ -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,19 +151,18 @@ export function SkusView({
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "var(--bg-2)",
|
||||
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)" />
|
||||
@@ -166,8 +174,8 @@ export function SkusView({
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
padding: "8px 0",
|
||||
fontSize: 13,
|
||||
padding: "10px 0",
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
@@ -188,45 +196,122 @@ export function SkusView({
|
||||
</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 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.map((r) => (
|
||||
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
|
||||
))}
|
||||
</Card>
|
||||
{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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user