feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface CardListProps {
|
||||||
|
children: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardList({ children, ariaLabel }: CardListProps) {
|
||||||
|
return (
|
||||||
|
<ul role="list" aria-label={ariaLabel} className="space-y-3 px-4 pb-24">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardListItemProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
as?: "li" | "div";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardListItem({ onClick, href, children, as = "li" }: CardListItemProps) {
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-4 transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-surface-2)",
|
||||||
|
minHeight: "72px",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a href={href} className="block active:scale-[0.99] transition-transform" style={{ minHeight: "72px" }}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="block w-full text-left active:scale-[0.99] transition-transform"
|
||||||
|
style={{ minHeight: "72px" }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <li>{content}</li>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="px-4 pt-6 pb-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-display font-display" style={{ fontWeight: 600, lineHeight: 1.15, letterSpacing: "-0.01em" }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-mono mt-1" style={{ color: "var(--color-text-muted)" }}>{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type StatusKind = "placed" | "ready" | "picked-up" | "cancelled" | "scheduled" | "in-transit" | "completed" | "low" | "out" | "in-stock" | "hidden";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<StatusKind, { bg: string; fg: string; label: string }> = {
|
||||||
|
placed: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Placed" },
|
||||||
|
ready: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Ready" },
|
||||||
|
"picked-up": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Picked up" },
|
||||||
|
cancelled: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Cancelled" },
|
||||||
|
scheduled: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Scheduled" },
|
||||||
|
"in-transit": { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "In transit" },
|
||||||
|
completed: { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Completed" },
|
||||||
|
low: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Low stock" },
|
||||||
|
out: { bg: "var(--color-danger-soft)", fg: "var(--color-danger)", label: "Out" },
|
||||||
|
"in-stock": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "In stock" },
|
||||||
|
hidden: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Hidden" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusPillProps {
|
||||||
|
status: StatusKind;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ status, label }: StatusPillProps) {
|
||||||
|
const cfg = STATUS_MAP[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-label font-semibold"
|
||||||
|
style={{ backgroundColor: cfg.bg, color: cfg.fg, letterSpacing: "0.02em", minHeight: "24px" }}
|
||||||
|
aria-label={cfg.label}
|
||||||
|
>
|
||||||
|
{label ?? cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusPill;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface StickyActionBarProps {
|
||||||
|
children: ReactNode;
|
||||||
|
pendingSync?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickyActionBar({ children, pendingSync = false }: StickyActionBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-20 px-4 py-3 border-t"
|
||||||
|
style={{
|
||||||
|
bottom: "60px", // sits above the mobile tab bar
|
||||||
|
backgroundColor: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-surface-3)",
|
||||||
|
boxShadow: "0 -4px 12px rgba(0,0,0,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingSync && (
|
||||||
|
<div className="text-label font-semibold mb-2 flex items-center gap-2" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full animate-pulse" style={{ backgroundColor: "var(--color-warning)" }} />
|
||||||
|
Pending sync
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StickyActionBar;
|
||||||
Reference in New Issue
Block a user