Phase 2 and Demo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user