feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust)

This commit is contained in:
Tyler
2026-06-17 15:07:17 -06:00
parent 8a912b1e62
commit 818bd4d47b
3 changed files with 388 additions and 0 deletions
@@ -0,0 +1,96 @@
"use client";
import { useRef, useState } from "react";
import { enqueueAction } from "@/lib/offline/queue";
import { syncPending } from "@/lib/offline/sync";
import { dispatchClientAction } from "@/actions/offline-dispatcher";
/**
* Long-press stepper to adjust a product's `inventory` count.
*
* UI plumbing only: pressing +/- enqueues an `adjustProductStock` mutation
* into the offline IndexedDB queue and triggers a sync pass. The server-side
* `adjustProductStock` action is not yet wired into the dispatcher's
* `listClientActions()` table (PR 3 deviation) — `dispatchClientAction` will
* return `{ok: false, error: "Unknown action"}` for now, which surfaces in
* the OfflineBanner conflict count. That's the known follow-up; the queue +
* sync pipeline is the deliverable here.
*
* The "long press" affordance keeps accidental single-tap stock changes to
* a minimum — a single tap on the trigger does nothing; the dialog only
* opens after holding the button for LONG_PRESS_MS. Inside the dialog the
* stepper buttons are large (64px) for gloved use.
*/
interface StockAdjustButtonProps {
productId: string;
currentStock: number;
}
const LONG_PRESS_MS = 500;
export function StockAdjustButton({ productId, currentStock }: StockAdjustButtonProps) {
const [open, setOpen] = useState(false);
const [stock, setStock] = useState(currentStock);
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function onPointerDown() {
timer.current = setTimeout(() => setOpen(true), LONG_PRESS_MS);
}
function onPointerUp() {
if (timer.current) clearTimeout(timer.current);
}
async function adjust(delta: number) {
const next = stock + delta;
if (next < 0) return;
setStock(next);
setPending(true);
try {
await enqueueAction("adjustProductStock", { productId, delta });
await syncPending(
(name, payload, id) => dispatchClientAction(name, payload, id),
{ online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }
);
} finally {
setPending(false);
}
}
return (
<>
<button
type="button"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
aria-label={`Adjust stock for product ${productId}`}
className="rounded-full"
style={{
minWidth: "40px",
minHeight: "40px",
backgroundColor: "var(--color-surface-3)",
color: "var(--color-text)",
}}
>
±
</button>
{open && (
<div role="dialog" aria-label="Adjust stock" className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
<div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
<div className="mt-6 flex items-center justify-center gap-6">
<button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}></button>
<div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
</div>
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
</div>
</div>
)}
</>
);
}
export default StockAdjustButton;