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 { 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+167
-10
@@ -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,6 +151,85 @@ export function SkusView({
|
|||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</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 }}>
|
<Card style={{ marginBottom: 14, padding: 14 }}>
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<div
|
<div
|
||||||
@@ -188,7 +276,6 @@ export function SkusView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
@@ -196,12 +283,9 @@ export function SkusView({
|
|||||||
>
|
>
|
||||||
<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
|
<Select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
onChange={(e) => setSortBy(e.target.value as SortKey)}
|
||||||
@@ -215,7 +299,6 @@ export function SkusView({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padded={false}>
|
<Card padded={false}>
|
||||||
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
<SkuHeaderRow sortBy={sortBy} onSort={setSortBy} />
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
@@ -227,6 +310,8 @@ export function SkusView({
|
|||||||
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
|
<SkuItemRow key={r.product.id} row={r} onClick={() => onSelectSku(r.product)} />
|
||||||
))}
|
))}
|
||||||
</Card>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user