feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Filter chips for the v2 Products list.
|
||||
*
|
||||
* The plan's filter vocabulary was "all" | "in-stock" | "low" | "out" | "hidden".
|
||||
* The real `products` schema has no `stock` or `hidden` columns — stock is
|
||||
* stored in `inventory` and visibility in `active` (no separate "hidden" flag).
|
||||
* The mapping is:
|
||||
* - "all" → no filter
|
||||
* - "in-stock" → inventory >= 10
|
||||
* - "low" → inventory > 0 AND inventory < 10
|
||||
* - "out" → inventory = 0
|
||||
* - "hidden" → active = false (treating "hidden" as "not visible to customers")
|
||||
*
|
||||
* Filter state is encoded in the `?filter=` query param so the server
|
||||
* component can re-render with the filter applied (no client-side state).
|
||||
*/
|
||||
const FILTERS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "in-stock", label: "In stock" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "out", label: "Out" },
|
||||
{ value: "hidden", label: "Hidden" },
|
||||
] as const;
|
||||
|
||||
export function ProductsFilterChips({ active }: { active: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function apply(value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value === "all") params.delete("filter");
|
||||
else params.set("filter", value);
|
||||
router.push(`/admin/v2/products?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-3 flex gap-2 overflow-x-auto" role="tablist" aria-label="Filter products">
|
||||
{FILTERS.map((f) => {
|
||||
const isActive = active === f.value;
|
||||
return (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => apply(f.value)}
|
||||
className="rounded-full text-label font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
minHeight: "40px",
|
||||
padding: "0 16px",
|
||||
backgroundColor: isActive ? "var(--color-accent)" : "var(--color-surface-2)",
|
||||
color: isActive ? "white" : "var(--color-text)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductsFilterChips;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user