Phase 2 and Demo

This commit is contained in:
2026-05-02 20:14:15 -05:00
parent d909cb7c30
commit 056bd27f89
36 changed files with 3867 additions and 299 deletions
@@ -0,0 +1,145 @@
/**
* AdminLayout — responsive sidebar shell for admin/management pages.
*
* Desktop (md+): fixed left sidebar (240px) + scrollable content area
* Mobile: collapsible drawer triggered by hamburger in top bar
*/
import { useState } from "react";
import { Outlet, NavLink, useNavigate } from "react-router-dom";
import { useAuthStore } from "../store/auth.js";
// ── Nav items ──────────────────────────────────────────────────────────────────
const ADMIN_NAV = [
{ to: "/admin", label: "Dashboard", emoji: "📊", exact: true },
{ to: "/admin/events", label: "Events", emoji: "🗓️", exact: false },
{ to: "/admin/items", label: "Items", emoji: "🏷️", exact: false },
{ to: "/admin/bidders", label: "Bidders", emoji: "🎟️", exact: false },
{ to: "/admin/checkout", label: "Checkout", emoji: "💳", exact: false },
{ to: "/admin/reporting", label: "Reporting", emoji: "📈", exact: false },
{ to: "/admin/fund-a-need",label: "Fund-a-Need", emoji: "💚", exact: false },
] as const;
const STAFF_NAV = [
{ to: "/staff/auctioneer", label: "Auctioneer", emoji: "🎙" },
{ to: "/staff/spotter", label: "Spotter", emoji: "👀" },
{ to: "/staff/check-in", label: "Check-in", emoji: "✅" },
{ to: "/display", label: "Display", emoji: "📺" },
] as const;
// ── Sidebar content ────────────────────────────────────────────────────────────
function SidebarContent({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const logout = useAuthStore((s) => s.logout);
const linkCls = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
isActive
? "bg-brand-700 text-white"
: "text-gray-600 hover:bg-brand-50 hover:text-brand-700"
}`;
return (
<div className="flex flex-col h-full">
{/* Logo / brand */}
<div className="px-4 py-5 border-b border-gray-100">
<button
onClick={() => { navigate("/admin"); onClose?.(); }}
className="flex flex-col leading-none select-none"
>
<span className="text-brand-700 font-black tracking-tight text-base">STORYBOOK FARM</span>
<span className="text-gold-500 font-semibold text-xs mt-0.5">Admin Console</span>
</button>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
<p className="section-title px-3 mb-2">Management</p>
{ADMIN_NAV.map(({ to, label, emoji, exact }) => (
<NavLink key={to} to={to} end={exact} className={linkCls} onClick={onClose}>
<span className="text-base leading-none">{emoji}</span>
{label}
</NavLink>
))}
<p className="section-title px-3 mt-5 mb-2">Staff Tools</p>
{STAFF_NAV.map(({ to, label, emoji }) => (
<NavLink key={to} to={to} className={linkCls} onClick={onClose}>
<span className="text-base leading-none">{emoji}</span>
{label}
</NavLink>
))}
</nav>
{/* Footer: logout */}
<div className="px-3 py-4 border-t border-gray-100">
<button
onClick={() => { logout(); navigate("/login"); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-red-500 hover:bg-red-50 transition-colors"
>
<span className="text-base leading-none">🚪</span>
Sign out
</button>
</div>
</div>
);
}
// ── Main layout ────────────────────────────────────────────────────────────────
export default function AdminLayout() {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
{/* ── Desktop sidebar (hidden on mobile) ── */}
<aside className="hidden md:flex md:flex-col w-60 bg-white border-r border-gray-100 flex-shrink-0">
<SidebarContent />
</aside>
{/* ── Mobile drawer overlay ── */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 md:hidden"
onClick={() => setDrawerOpen(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/40" />
{/* Drawer panel */}
<aside
className="absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<SidebarContent onClose={() => setDrawerOpen(false)} />
</aside>
</div>
)}
{/* ── Main area ── */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* Mobile top bar */}
<header className="md:hidden flex items-center h-14 px-4 bg-white border-b border-gray-100 gap-3">
<button
aria-label="Open menu"
onClick={() => setDrawerOpen(true)}
className="p-2 rounded-xl hover:bg-gray-100 transition-colors"
>
<HamburgerIcon />
</button>
<span className="font-black text-brand-700 tracking-tight">STORYBOOK FARM</span>
<span className="text-gold-500 font-semibold text-xs ml-1">Admin</span>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
);
}
function HamburgerIcon() {
return (
<svg className="w-5 h-5 text-brand-700" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
@@ -0,0 +1,174 @@
/**
* BidderLayout — shell wrapping all attendee-facing pages.
*
* ┌─────────────────────────────┐
* │ Header (logo + connectivity) │
* ├─────────────────────────────┤
* │ │
* │ <Outlet /> │
* │ │
* ├─────────────────────────────┤
* │ Bottom nav (5 tabs) │
* └─────────────────────────────┘
*/
import { Outlet, NavLink, useNavigate } from "react-router-dom";
import { useConnectivityStore } from "../store/connectivity.js";
import { useAuthStore } from "../store/auth.js";
// ── Nav tab definitions ────────────────────────────────────────────────────────
const NAV_TABS = [
{ to: "/", label: "Home", icon: HomeIcon },
{ to: "/live", label: "Live", icon: MicIcon },
{ to: "/silent", label: "Silent", icon: TagIcon },
{ to: "/my-bids", label: "My Bids", icon: ListIcon },
{ to: "/profile", label: "Profile", icon: UserIcon },
] as const;
// ── Inline SVG icon components ─────────────────────────────────────────────────
function HomeIcon({ cls }: { cls: string }) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
d="M2.25 12L11.204 3.046a1.125 1.125 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
);
}
function MicIcon({ cls }: { cls: string }) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
</svg>
);
}
function TagIcon({ cls }: { cls: string }) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
);
}
function ListIcon({ cls }: { cls: string }) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
);
}
function UserIcon({ cls }: { cls: string }) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
);
}
// ── Connectivity dot in header ─────────────────────────────────────────────────
function ConnectivityDot() {
const status = useConnectivityStore((s) => s.status);
const colorMap = { connected: "bg-emerald-400", local: "bg-gold-400", offline: "bg-red-400" };
const labelMap = { connected: "Online", local: "Local", offline: "Offline" };
return (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<span className={`w-2 h-2 rounded-full ${colorMap[status]}`} />
{labelMap[status]}
</span>
);
}
// ── Main layout ────────────────────────────────────────────────────────────────
export default function BidderLayout() {
const navigate = useNavigate();
const bidder = useAuthStore((s) => s.bidder);
return (
<div className="flex flex-col min-h-screen bg-gray-50">
{/* ── Header ── */}
<header className="bg-brand-700 text-white px-4 pt-[env(safe-area-inset-top,0px)]">
<div className="flex items-center justify-between h-14">
{/* Brand name */}
<button
onClick={() => navigate("/")}
className="flex items-center gap-2 select-none"
>
<span className="text-gold-300 font-black tracking-tight text-lg leading-none">
STORYBOOK
</span>
<span className="text-white/80 font-semibold text-xs leading-none mt-px">
FARM
</span>
</button>
{/* Right side: connectivity + paddle */}
<div className="flex items-center gap-3">
<ConnectivityDot />
{bidder?.paddleNumber && (
<span className="bg-gold-500 text-white text-xs font-black px-2.5 py-1 rounded-full">
#{bidder.paddleNumber}
</span>
)}
</div>
</div>
</header>
{/* ── Offline/local banner (only non-connected) ── */}
<OfflineBanner />
{/* ── Page content ── */}
<main className="flex-1 overflow-y-auto pb-safe">
<Outlet />
</main>
{/* ── Bottom navigation ── */}
<nav className="bottom-nav fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 shadow-[0_-1px_0_0_rgb(0_0_0/0.05)] z-40">
<ul className="flex h-16">
{NAV_TABS.map(({ to, label, icon: Icon }) => (
<li key={to} className="flex-1">
<NavLink
to={to}
end={to === "/"}
className={({ isActive }) =>
`flex flex-col items-center justify-center h-full gap-0.5 text-[10px] font-semibold transition-colors ${
isActive
? "text-brand-700"
: "text-gray-400 hover:text-brand-600"
}`
}
>
{({ isActive }) => (
<>
<Icon cls={`w-5 h-5 ${isActive ? "stroke-brand-700" : "stroke-gray-400"}`} />
{label}
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
</div>
);
}
// ── Inline offline banner (below header) ──────────────────────────────────────
function OfflineBanner() {
const status = useConnectivityStore((s) => s.status);
if (status === "connected") return null;
const configs = {
local: { bg: "bg-gold-500", text: "Local network — offline-capable" },
offline: { bg: "bg-red-500", text: "Offline — bids will sync when reconnected" },
};
const cfg = configs[status as keyof typeof configs];
if (!cfg) return null;
return (
<div className={`${cfg.bg} text-white text-center text-xs py-1.5 px-4 font-medium`}>
{cfg.text}
</div>
);
}
@@ -1,21 +1,26 @@
/**
* Thin connectivity banner — used on pages outside the BidderLayout shell
* (auth pages, staff tools, display board).
*
* Hidden when fully connected.
*/
import { useConnectivityStore } from "../store/connectivity.js";
const labels: Record<string, { text: string; className: string }> = {
connected: { text: "Connected", className: "bg-green-500" },
local: { text: "Local network offline-capable", className: "bg-yellow-500" },
offline: { text: "Offline bids will sync when reconnected", className: "bg-red-500" },
};
const CONFIGS = {
local: { bg: "bg-gold-500", text: "Local network — offline-capable" },
offline: { bg: "bg-red-500", text: "Offline — bids will sync when reconnected" },
} as const;
export function ConnectivityBanner() {
const status = useConnectivityStore((s) => s.status);
if (status === "connected") return null;
const { text, className } = labels[status]!;
const cfg = CONFIGS[status as keyof typeof CONFIGS];
if (!cfg) return null;
return (
<div className={`${className} text-white text-center text-sm py-1 px-4 font-medium`}>
{text}
<div className={`${cfg.bg} text-white text-center text-xs py-1.5 px-4 font-semibold tracking-wide`}>
{cfg.text}
</div>
);
}