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
+36 -32
View File
@@ -1,5 +1,8 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ConnectivityBanner } from "./components/ConnectivityBanner.js";
// Layouts
import BidderLayout from "./components/BidderLayout.js";
import AdminLayout from "./components/AdminLayout.js";
// Bidder-facing pages
import HomePage from "./pages/bidder/HomePage.js";
@@ -14,7 +17,7 @@ import ProfilePage from "./pages/bidder/ProfilePage.js";
import LoginPage from "./pages/auth/LoginPage.js";
import VerifyPage from "./pages/auth/VerifyPage.js";
// Staff pages
// Staff pages (full-screen, no shared shell)
import AuctioneerPage from "./pages/staff/AuctioneerPage.js";
import SpotterPage from "./pages/staff/SpotterPage.js";
import CheckInPage from "./pages/staff/CheckInPage.js";
@@ -31,39 +34,40 @@ import FundANeedPage from "./pages/admin/FundANeedPage.js";
export default function App() {
return (
<>
<ConnectivityBanner />
<Routes>
{/* Auth */}
<Route path="/login" element={<LoginPage />} />
<Route path="/verify" element={<VerifyPage />} />
<Routes>
{/* ── Auth (no layout) ── */}
<Route path="/login" element={<LoginPage />} />
<Route path="/verify" element={<VerifyPage />} />
{/* Bidder */}
<Route path="/" element={<HomePage />} />
<Route path="/live" element={<LivePage />} />
<Route path="/silent" element={<SilentPage />} />
{/* ── Staff tools (full-screen, no layout chrome) ── */}
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
<Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* ── Bidder shell ── */}
<Route element={<BidderLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/live" element={<LivePage />} />
<Route path="/silent" element={<SilentPage />} />
<Route path="/items/:id" element={<ItemPage />} />
<Route path="/my-bids" element={<MyBidsPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/my-bids" element={<MyBidsPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
{/* Staff optimized single-task views */}
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
<Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* ── Admin shell ── */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="events" element={<AdminEventsPage />} />
<Route path="items" element={<AdminItemsPage />} />
<Route path="bidders" element={<AdminBiddersPage />} />
<Route path="checkout" element={<AdminCheckoutPage />} />
<Route path="reporting" element={<AdminReportingPage />} />
<Route path="fund-a-need" element={<FundANeedPage />} />
</Route>
{/* Admin */}
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/events" element={<AdminEventsPage />} />
<Route path="/admin/items" element={<AdminItemsPage />} />
<Route path="/admin/bidders" element={<AdminBiddersPage />} />
<Route path="/admin/checkout" element={<AdminCheckoutPage />} />
<Route path="/admin/reporting" element={<AdminReportingPage />} />
<Route path="/admin/fund-a-need" element={<FundANeedPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
@@ -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>
);
}
@@ -0,0 +1,156 @@
/**
* Auctioneer console hook.
*
* Manages the full live auction control flow:
* - Subscribes to all live-auction server events
* - Emits auctioneer_* socket events for each control action
* - Keeps a local snapshot of the current item, called amount, and bid stream
*/
import { useState, useEffect, useCallback } from "react";
import { getSocket } from "../lib/socket.js";
import { api } from "../lib/api.js";
import type { AuctionItem, Bid, ItemState } from "@storybid/shared";
export interface AuctioneerState {
items: AuctionItem[]; // all lots for the auction
currentItem: AuctionItem | null;
currentBid: number | null;
calledAmount: number | null;
state: ItemState | null;
recentBids: Bid[];
loading: boolean;
}
export function useAuctioneerControls(eventId: string, auctionId: string) {
const [state, setState] = useState<AuctioneerState>({
items: [],
currentItem: null,
currentBid: null,
calledAmount: null,
state: null,
recentBids: [],
loading: true,
});
// Load item list on mount
useEffect(() => {
api
.get<AuctionItem[]>(`/api/items?auctionId=${auctionId}`)
.then((items) => setState((s) => ({ ...s, items, loading: false })))
.catch(() => setState((s) => ({ ...s, loading: false })));
}, [auctionId]);
// Real-time subscriptions
useEffect(() => {
const socket = getSocket();
socket.emit("join_event", eventId);
socket.on("item_activated", ({ item }) => {
setState((s) => ({
...s,
currentItem: item,
currentBid: item.currentHighBid,
calledAmount: item.openingBid,
state: item.state,
recentBids: [],
// Keep item list in sync
items: s.items.map((i) => (i.id === item.id ? item : i)),
}));
});
socket.on("next_live_bid", ({ amount }) => {
setState((s) => ({ ...s, calledAmount: amount }));
});
socket.on("live_bid_accepted", ({ bid, item }) => {
setState((s) => ({
...s,
currentItem: item,
currentBid: item.currentHighBid,
state: item.state,
recentBids: [bid, ...s.recentBids].slice(0, 20),
items: s.items.map((i) => (i.id === item.id ? item : i)),
}));
});
socket.on("item_state_changed", ({ itemId, state: newState }) => {
setState((s) => ({
...s,
state: s.currentItem?.id === itemId ? newState : s.state,
items: s.items.map((i) => (i.id === itemId ? { ...i, state: newState } : i)),
}));
});
socket.on("item_sold", ({ itemId, amount }) => {
setState((s) => ({
...s,
state: s.currentItem?.id === itemId ? "sold" : s.state,
currentBid: s.currentItem?.id === itemId ? amount : s.currentBid,
items: s.items.map((i) =>
i.id === itemId ? { ...i, state: "sold", currentHighBid: amount } : i,
),
}));
});
return () => {
socket.emit("leave_event", eventId);
socket.off("item_activated");
socket.off("next_live_bid");
socket.off("live_bid_accepted");
socket.off("item_state_changed");
socket.off("item_sold");
};
}, [eventId]);
// ── Controls ──────────────────────────────────────────────────────────────────
const activateItem = useCallback((itemId: string) => {
getSocket().emit("auctioneer_activate_item", itemId);
}, []);
const callNextBid = useCallback((itemId: string, amount: number) => {
getSocket().emit("auctioneer_call_next_bid", { itemId, amount });
setState((s) => ({ ...s, calledAmount: amount }));
}, []);
const acceptBid = useCallback((itemId: string, bidderId: string, amount: number) => {
getSocket().emit("auctioneer_accept_bid", { itemId, bidderId, amount });
}, []);
const goingOnce = useCallback((itemId: string) => {
getSocket().emit("auctioneer_going_once", itemId);
}, []);
const goingTwice = useCallback((itemId: string) => {
getSocket().emit("auctioneer_going_twice", itemId);
}, []);
const sold = useCallback((itemId: string) => {
getSocket().emit("auctioneer_sold", itemId);
}, []);
const pass = useCallback((itemId: string) => {
getSocket().emit("auctioneer_pass", itemId);
}, []);
/** Suggest next bid = current high bid + increment (or opening bid if no bids yet) */
const suggestNextBid = useCallback((): number => {
const item = state.currentItem;
if (!item) return 0;
return item.currentHighBid != null
? item.currentHighBid + item.bidIncrement
: item.openingBid;
}, [state.currentItem]);
return {
...state,
activateItem,
callNextBid,
acceptBid,
goingOnce,
goingTwice,
sold,
pass,
suggestNextBid,
};
}
+95 -2
View File
@@ -2,13 +2,106 @@
@tailwind components;
@tailwind utilities;
/* ── CSS custom properties ─────────────────────────────────────────────────── */
:root {
--color-brand: #2b5916;
--color-brand-dark: #1e3f10;
--color-gold: #c4952a;
--color-gold-light: #f4da99;
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-top: env(safe-area-inset-top, 0px);
}
/* ── Base ───────────────────────────────────────────────────────────────────── */
@layer base {
html {
/* Prevent text-size inflation on mobile */
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
/* Prevents iOS rubber-band on the outer scroll */
overscroll-behavior: none;
}
body {
@apply bg-white text-gray-900 antialiased;
@apply bg-gray-50 text-gray-900 antialiased;
font-feature-settings: "kern" 1, "liga" 1;
}
/* Make all tap highlights match brand */
* {
-webkit-tap-highlight-color: rgb(43 89 22 / 0.12);
}
h1, h2, h3, h4, h5, h6 {
@apply font-bold tracking-tight;
}
}
/* ── Component layer ────────────────────────────────────────────────────────── */
@layer components {
/* Primary button */
.btn-primary {
@apply inline-flex items-center justify-center gap-2
rounded-xl px-5 py-3
bg-brand-700 text-white font-semibold text-sm
shadow-bid-btn
hover:bg-brand-800
active:scale-[0.97]
transition-all duration-150
disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none;
}
/* Ghost / secondary button */
.btn-ghost {
@apply inline-flex items-center justify-center gap-2
rounded-xl px-5 py-3
border border-brand-200 bg-white text-brand-700 font-semibold text-sm
hover:bg-brand-50
active:scale-[0.97]
transition-all duration-150
disabled:opacity-40 disabled:cursor-not-allowed;
}
/* Gold accent button */
.btn-gold {
@apply inline-flex items-center justify-center gap-2
rounded-xl px-5 py-3
bg-gold-500 text-white font-semibold text-sm
hover:bg-gold-600
active:scale-[0.97]
transition-all duration-150
disabled:opacity-40 disabled:cursor-not-allowed;
}
/* Card surface */
.card {
@apply bg-white rounded-2xl shadow-card border border-gray-100;
}
/* Form input */
.field {
@apply w-full rounded-xl border border-gray-200 bg-white
px-4 py-3 text-sm text-gray-900
placeholder:text-gray-400
focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20
transition-colors;
}
/* Subtle badge */
.badge {
@apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
/* Section header inside a page */
.section-title {
@apply text-xs uppercase tracking-widest font-semibold text-gray-400;
}
}
/* ── Bottom nav safe area ───────────────────────────────────────────────────── */
.pb-safe {
padding-bottom: calc(4rem + var(--safe-bottom));
}
.bottom-nav {
padding-bottom: var(--safe-bottom);
}
@@ -1,22 +1,23 @@
/**
* Admin → Bidders profiles, paddles, QR codes, CSV import.
* Admin → Bidders profiles, paddles, QR codes, CSV import.
* TODO: CRUD + bulk import via /api/bidders.
*/
export default function AdminBiddersPage() {
return (
<main className="p-6 space-y-4">
<div className="p-6 space-y-5 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Bidder Manager</h1>
<div>
<h1 className="text-2xl font-black text-gray-900">Bidders</h1>
<p className="text-sm text-gray-400 mt-0.5">Profiles, paddles, QR codes</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-2 border rounded-lg text-sm">Import CSV</button>
<button className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
+ Add Bidder
</button>
<button className="btn-ghost text-sm">Import CSV</button>
<button className="btn-primary">+ Add Bidder</button>
</div>
</div>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Bidder list not yet implemented
</div>
</main>
</div>
);
}
@@ -1,14 +1,23 @@
/**
* Admin → Checkout cashier station; find bidder, take payment, print receipt.
* Admin → Checkout cashier station; find bidder, take payment, print receipt.
* TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture.
*/
export default function AdminCheckoutPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Checkout</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="p-6 space-y-5 max-w-3xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Checkout</h1>
<p className="text-sm text-gray-400 mt-0.5">Cashier station search by paddle or name</p>
</div>
<input
type="search"
placeholder="Search paddle # or bidder name…"
className="field"
disabled
/>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Cashier station not yet implemented
</div>
</main>
</div>
);
}
@@ -1,19 +1,41 @@
/**
* Admin dashboard overview of events, recent bids, revenue snapshot.
* Admin dashboard overview of events, recent bids, revenue snapshot.
* TODO: fetch org summary from /api/reporting.
*/
export default function AdminDashboard() {
const stats = [
{ label: "Events", value: "—", icon: "🗓️" },
{ label: "Bidders", value: "—", icon: "🎟️" },
{ label: "Revenue", value: "—", icon: "💰" },
];
return (
<main className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<div className="p-6 space-y-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-400 mt-0.5">Storybook Farm Auction Platform</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{["Events", "Bidders", "Revenue"].map((label) => (
<div key={label} className="border rounded-xl p-5 text-center">
<p className="text-gray-500 text-sm">{label}</p>
<p className="text-3xl font-bold mt-1"></p>
{stats.map(({ label, value, icon }) => (
<div key={label} className="card p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-xl leading-none">{icon}</span>
<p className="text-sm font-semibold text-gray-500">{label}</p>
</div>
<p className="text-3xl font-black text-brand-700 tabular-nums">{value}</p>
</div>
))}
</div>
</main>
{/* Placeholder activity feed */}
<div className="card p-5">
<p className="section-title mb-4">Recent Activity</p>
<div className="border border-dashed border-gray-200 rounded-xl p-8 text-center text-gray-400 text-sm">
Activity feed not yet implemented
</div>
</div>
</div>
);
}
@@ -1,19 +1,22 @@
/**
* Admin → Events list, create, edit events.
* Admin → Events list, create, edit events.
* TODO: CRUD via /api/events.
*/
export default function AdminEventsPage() {
return (
<main className="p-6 space-y-4">
<div className="p-6 space-y-5 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Events</h1>
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
<div>
<h1 className="text-2xl font-black text-gray-900">Events</h1>
<p className="text-sm text-gray-400 mt-0.5">Manage auctions and event settings</p>
</div>
<button className="btn-primary">
+ New Event
</button>
</div>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Events list not yet implemented
</div>
</main>
</div>
);
}
@@ -1,14 +1,17 @@
/**
* Admin → Fund-a-Need / Paddle Raise set tiers, open campaign, show live total.
* Admin → Fund-a-Need / Paddle Raise set tiers, open campaign, show live total.
* TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events.
*/
export default function FundANeedPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Fund-a-Need</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Paddle raise setup & live totals not yet implemented
<div className="p-6 space-y-5 max-w-3xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Fund-a-Need</h1>
<p className="text-sm text-gray-400 mt-0.5">Paddle raise setup &amp; live totals</p>
</div>
</main>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Paddle raise setup &amp; live totals not yet implemented
</div>
</div>
);
}
+14 -6
View File
@@ -1,14 +1,22 @@
/**
* Admin → Items manage lots, categories, media, donor info, increments.
* Admin → Items manage lots, categories, media, donor info, increments.
* TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart).
*/
export default function AdminItemsPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Item Manager</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Item list & editor not yet implemented
<div className="p-6 space-y-5 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-gray-900">Items</h1>
<p className="text-sm text-gray-400 mt-0.5">Lots, media, donor info, bid increments</p>
</div>
<button className="btn-primary">
+ Add Item
</button>
</div>
</main>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Item list &amp; editor not yet implemented
</div>
</div>
);
}
@@ -1,14 +1,17 @@
/**
* Admin → Reporting revenue, sell-through, bidder activity, audit log.
* Admin → Reporting revenue, sell-through, bidder activity, audit log.
* TODO: fetch /api/reporting/events/:id/*.
*/
export default function AdminReportingPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Reporting</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="p-6 space-y-5 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Reporting</h1>
<p className="text-sm text-gray-400 mt-0.5">Revenue, sell-through, audit log</p>
</div>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Reports not yet implemented
</div>
</main>
</div>
);
}
+221 -11
View File
@@ -1,18 +1,228 @@
/**
* Login email magic link or SMS OTP entry point.
* TODO: implement magic-link request form and OTP flow.
* Login email magic link or SMS OTP.
*
* Two tabs: Email and Phone.
* Email flow : POST /api/auth/magic-link → success message
* Phone flow : POST /api/auth/otp/send → OTP entry → POST /api/auth/otp/verify → JWT
*/
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../../lib/api.js";
import { useAuthStore } from "../../store/auth.js";
import type { Bidder } from "@storybid/shared";
type Tab = "email" | "phone";
type PhasePhone = "entry" | "otp";
export default function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<Tab>("email");
// Email state
const [email, setEmail] = useState("");
const [emailSent, setEmailSent] = useState(false);
const [emailLoading, setEmailLoading] = useState(false);
const [emailError, setEmailError] = useState("");
// Phone state
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [phonePhase, setPhonePhase] = useState<PhasePhone>("entry");
const [phoneLoading, setPhoneLoading] = useState(false);
const [phoneError, setPhoneError] = useState("");
// ── Email handlers ────────────────────────────────────────────────────────────
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setEmailError("");
setEmailLoading(true);
try {
await api.post("/api/auth/magic-link", { email });
setEmailSent(true);
} catch {
setEmailError("Could not send link. Check the email address and try again.");
} finally {
setEmailLoading(false);
}
};
// ── Phone handlers ────────────────────────────────────────────────────────────
const handlePhoneSend = async (e: React.FormEvent) => {
e.preventDefault();
setPhoneError("");
setPhoneLoading(true);
const normalised = phone.startsWith("+") ? phone : `+1${phone.replace(/\D/g, "")}`;
try {
await api.post("/api/auth/otp/send", { phone: normalised });
setPhone(normalised);
setPhonePhase("otp");
} catch (err: unknown) {
setPhoneError(
err instanceof Error ? err.message : "Could not send code. Check your phone number.",
);
} finally {
setPhoneLoading(false);
}
};
const handleOtpVerify = async (e: React.FormEvent) => {
e.preventDefault();
setPhoneError("");
setPhoneLoading(true);
try {
const deviceId = localStorage.getItem("sb_device_id") ?? undefined;
const res = await api.post<{ token: string; bidder: Bidder; role: string }>(
"/api/auth/otp/verify",
{ phone, code: otp, deviceId },
);
setAuth(res.token, res.bidder, res.role);
navigate("/");
} catch {
setPhoneError("Incorrect or expired code. Please try again.");
} finally {
setPhoneLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-bold text-center">Sign in to bid</h1>
<p className="text-center text-gray-500 text-sm">
Enter your email for a magic link, or your phone number for a one-time code.
</p>
{/* TODO: LoginForm component */}
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm">
LoginForm not yet implemented
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm overflow-hidden rounded-2xl shadow-card-lg border border-gray-100 bg-white animate-fade-in">
{/* ── Brand header ── */}
<div className="bg-brand-700 px-6 py-8 text-center">
<p className="text-gold-300 font-black text-2xl tracking-tight leading-none">
STORYBOOK FARM
</p>
<p className="text-brand-200 text-sm mt-1.5">Sign in to bid</p>
</div>
{/* ── Tabs ── */}
<div className="flex border-b border-gray-100">
{(["email", "phone"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
tab === t
? "text-brand-700 border-b-2 border-brand-700"
: "text-gray-400 hover:text-gray-600"
}`}
>
{t === "email" ? "✉️ Email" : "📱 Phone"}
</button>
))}
</div>
<div className="p-6">
{/* ── Email tab ── */}
{tab === "email" && (
emailSent ? (
<div className="text-center space-y-3 py-2">
<div className="text-4xl">📬</div>
<p className="font-bold text-gray-900">Check your inbox</p>
<p className="text-sm text-gray-500">
We sent a sign-in link to <strong>{email}</strong>. The link expires in 15 minutes.
</p>
<button
onClick={() => { setEmailSent(false); setEmail(""); }}
className="text-brand-700 text-sm font-semibold hover:underline"
>
Use a different email
</button>
</div>
) : (
<form onSubmit={(e) => void handleEmailSubmit(e)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Email address
</label>
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="field"
/>
</div>
{emailError && <p className="text-red-500 text-sm">{emailError}</p>}
<button type="submit" disabled={emailLoading} className="btn-primary w-full">
{emailLoading ? "Sending…" : "Send sign-in link"}
</button>
</form>
)
)}
{/* ── Phone tab ── */}
{tab === "phone" && (
phonePhase === "entry" ? (
<form onSubmit={(e) => void handlePhoneSend(e)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Phone number
</label>
<input
type="tel"
required
autoFocus
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 000-0000"
className="field"
/>
<p className="text-xs text-gray-400 mt-1.5">
US numbers: enter 10 digits. International: include country code (+XX).
</p>
</div>
{phoneError && <p className="text-red-500 text-sm">{phoneError}</p>}
<button type="submit" disabled={phoneLoading} className="btn-primary w-full">
{phoneLoading ? "Sending…" : "Send code"}
</button>
</form>
) : (
<form onSubmit={(e) => void handleOtpVerify(e)} className="space-y-4">
<p className="text-sm text-gray-500 text-center">
We sent a 6-digit code to <strong>{phone}</strong>
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Verification code
</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
required
autoFocus
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="field text-center text-2xl font-bold tracking-[0.4em]"
/>
</div>
{phoneError && <p className="text-red-500 text-sm">{phoneError}</p>}
<button
type="submit"
disabled={phoneLoading || otp.length < 4}
className="btn-primary w-full"
>
{phoneLoading ? "Verifying…" : "Sign in"}
</button>
<button
type="button"
onClick={() => { setPhonePhase("entry"); setOtp(""); setPhoneError(""); }}
className="w-full text-sm text-gray-400 hover:text-gray-600 py-1"
>
Use a different number
</button>
</form>
)
)}
</div>
</div>
</main>
+80 -4
View File
@@ -1,11 +1,87 @@
/**
* Verify handles magic-link ?token= callback and OTP confirmation.
* TODO: read token from URL, call /api/auth/verify, redirect to /.
* Verify handles magic-link ?token= callback.
*
* Called when the user clicks the sign-in link from their email.
* Reads ?token= from the URL, calls GET /api/auth/verify, stores the JWT,
* then redirects to / (or the originally requested page via ?next=).
*/
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuthStore } from "../../store/auth.js";
import type { Bidder } from "@storybid/shared";
type Phase = "verifying" | "success" | "error";
export default function VerifyPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [phase, setPhase] = useState<Phase>("verifying");
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
const token = searchParams.get("token");
const next = searchParams.get("next") ?? "/";
if (!token) {
setPhase("error");
setErrorMsg("No verification token found. Check the link in your email.");
return;
}
void (async () => {
try {
const res = await fetch(`/api/auth/verify?token=${encodeURIComponent(token)}`);
const data = (await res.json()) as { token?: string; bidder?: Bidder; role?: string; error?: string };
if (!res.ok || !data.token) {
throw new Error(data.error ?? "Verification failed");
}
setAuth(data.token, data.bidder!, data.role ?? "bidder");
setPhase("success");
// Brief success flash then redirect
setTimeout(() => navigate(next, { replace: true }), 800);
} catch (err) {
setPhase("error");
setErrorMsg(
err instanceof Error ? err.message : "This link may have expired. Request a new one.",
);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="min-h-screen flex items-center justify-center p-4">
<p className="text-gray-500">Verifying</p>
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="text-center space-y-4 max-w-sm">
{phase === "verifying" && (
<>
<div className="text-4xl animate-spin inline-block"></div>
<p className="text-gray-600 font-medium">Verifying your link</p>
</>
)}
{phase === "success" && (
<>
<div className="text-5xl"></div>
<p className="text-gray-700 font-semibold">Signed in! Redirecting</p>
</>
)}
{phase === "error" && (
<>
<div className="text-5xl"></div>
<p className="text-red-600 font-semibold">Sign-in failed</p>
<p className="text-gray-500 text-sm">{errorMsg}</p>
<a href="/login" className="btn-primary">
Back to sign in
</a>
</>
)}
</div>
</main>
);
}
@@ -1,14 +1,14 @@
/**
* Bidder checkout shows won lots, total, and Stripe Payment Element.
* Bidder checkout shows won lots, total, and Stripe Payment Element.
* TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success.
*/
export default function CheckoutPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Checkout</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Checkout</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Stripe checkout not yet implemented
</div>
</main>
</div>
);
}
+94 -21
View File
@@ -1,27 +1,100 @@
/**
* Bidder home event welcome screen, quick nav to Live / Silent / My Bids.
* TODO: fetch event details, show upcoming lots, paddle number, QR code.
* Bidder home welcome screen, quick nav cards, event status strip.
* TODO: fetch active event details from /api/events/active.
*/
import { Link } from "react-router-dom";
import { useAuthStore, bidderName } from "../../store/auth.js";
// ── Quick-action cards ─────────────────────────────────────────────────────────
const QUICK_ACTIONS = [
{
to: "/live",
label: "Live Auction",
sub: "Bid in real time",
bg: "bg-brand-700",
text: "text-white",
sub_text: "text-brand-200",
icon: "🎙",
},
{
to: "/silent",
label: "Silent Auction",
sub: "Browse & place bids",
bg: "bg-gold-500",
text: "text-white",
sub_text: "text-gold-100",
icon: "🏷️",
},
{
to: "/my-bids",
label: "My Bids",
sub: "Track your activity",
bg: "bg-white",
text: "text-brand-800",
sub_text: "text-gray-400",
icon: "📋",
border: true,
},
{
to: "/checkout",
label: "Checkout",
sub: "Pay for won items",
bg: "bg-white",
text: "text-brand-800",
sub_text: "text-gray-400",
icon: "💳",
border: true,
},
] as const;
export default function HomePage() {
const bidder = useAuthStore((s) => s.bidder);
return (
<main className="p-4 space-y-4">
<h1 className="text-2xl font-bold">Welcome to the Auction</h1>
<nav className="grid grid-cols-2 gap-3">
{[
{ label: "🎙 Live Auction", href: "/live" },
{ label: "🔇 Silent Auction", href: "/silent" },
{ label: "📋 My Bids", href: "/my-bids" },
{ label: "💳 Checkout", href: "/checkout" },
].map(({ label, href }) => (
<a
key={href}
href={href}
className="block rounded-xl border border-gray-200 p-5 text-center font-semibold text-brand-700 hover:bg-brand-50"
>
{label}
</a>
))}
</nav>
</main>
<div className="flex flex-col min-h-full">
{/* ── Hero strip ── */}
<div className="bg-brand-700 px-5 pt-6 pb-8">
<p className="text-brand-200 text-xs font-semibold uppercase tracking-widest mb-1">
Welcome back
</p>
<h1 className="text-white text-2xl font-black tracking-tight leading-tight">
{bidderName(bidder)}
</h1>
{bidder?.paddleNumber && (
<div className="inline-flex items-center gap-2 mt-3 bg-brand-800/60 rounded-xl px-3 py-1.5">
<span className="text-gold-300 font-black text-lg">#{bidder.paddleNumber}</span>
<span className="text-brand-200 text-xs">Your paddle number</span>
</div>
)}
</div>
{/* ── Cards grid (overlaps hero by 1rem) ── */}
<div className="px-4 -mt-4 space-y-3">
{/* Event status card */}
<div className="card px-4 py-3 flex items-center gap-3 animate-fade-in">
<span className="text-2xl">🌿</span>
<div>
<p className="font-semibold text-sm text-gray-800">Storybook Farm Charity Gala</p>
<p className="text-xs text-gray-400 mt-0.5">Auction is live good luck!</p>
</div>
<span className="ml-auto w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
</div>
{/* Quick-action grid */}
<div className="grid grid-cols-2 gap-3 pb-4">
{QUICK_ACTIONS.map(({ to, label, sub, bg, text, sub_text, icon, border }) => (
<Link
key={to}
to={to}
className={`rounded-2xl p-4 flex flex-col gap-1 shadow-card active:scale-[0.97] transition-transform ${bg} ${border ? "border border-gray-100" : ""}`}
>
<span className="text-2xl leading-none">{icon}</span>
<p className={`font-bold text-sm mt-1 ${text}`}>{label}</p>
<p className={`text-xs ${sub_text}`}>{sub}</p>
</Link>
))}
</div>
</div>
</div>
);
}
@@ -7,12 +7,17 @@
* - Media carousel (images, video embed, documents)
* - Place bid form with offline-outbox fallback via db.outbox
*/
import { useParams } from "react-router-dom";
export default function ItemPage() {
const { id } = useParams<{ id: string }>();
return (
<main className="p-4 space-y-4">
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Item #{id}</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Item detail not yet implemented
</div>
</main>
</div>
);
}
+51 -42
View File
@@ -9,17 +9,16 @@ import { useParams } from "react-router-dom";
import { useLiveAuction } from "../../hooks/useLiveAuction.js";
import { useOfflineBids } from "../../hooks/useOfflineBids.js";
const STATE_LABELS: Record<string, string> = {
preview: "Up next",
active: "Bidding open",
going_once: "Going once…",
going_twice: "Going twice…",
sold: "SOLD",
passed: "Passed",
const STATE_META: Record<string, { label: string; color: string }> = {
preview: { label: "Up next", color: "bg-brand-100 text-brand-700" },
active: { label: "Bidding open", color: "bg-emerald-100 text-emerald-700" },
going_once: { label: "Going once…", color: "bg-gold-100 text-gold-700" },
going_twice: { label: "Going twice…", color: "bg-orange-100 text-orange-700" },
sold: { label: "SOLD", color: "bg-gray-100 text-gray-500" },
passed: { label: "Passed", color: "bg-gray-100 text-gray-400" },
};
export default function LivePage() {
// eventId comes from route or a global store; use param or fallback
const { eventId = "" } = useParams<{ eventId?: string }>();
const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } =
useLiveAuction(eventId);
@@ -32,66 +31,73 @@ export default function LivePage() {
placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq);
};
const isSold = state === "sold" || state === "passed";
const canBid = state === "active" || state === "going_once" || state === "going_twice";
const meta = state ? (STATE_META[state] ?? { label: state, color: "bg-gray-100 text-gray-500" }) : null;
return (
<main className="min-h-screen flex flex-col p-4 gap-6">
{/* Status banner */}
<div className="text-center">
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold">
Live Auction
</p>
{state && (
<span
className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-bold ${
isSold ? "bg-gray-200 text-gray-500" : "bg-brand-100 text-brand-700"
}`}
>
{STATE_LABELS[state] ?? state}
</span>
)}
</div>
<div className="flex flex-col p-4 gap-5 animate-fade-in">
{/* ── Section header ── */}
<p className="section-title text-center">Live Auction</p>
{currentItem ? (
<>
{/* State badge */}
{meta && (
<div className="flex justify-center">
<span className={`badge text-sm px-4 py-1.5 ${meta.color}`}>
{meta.label}
</span>
</div>
)}
{/* Item info */}
<div className="text-center space-y-1">
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
<h1 className="text-2xl font-bold">{currentItem.title}</h1>
<div className="card p-5 text-center space-y-1 animate-slide-up">
<p className="text-xs text-gray-400 uppercase tracking-widest">
Lot {currentItem.lotNumber}
</p>
<h2 className="text-xl font-bold text-gray-900">{currentItem.title}</h2>
{currentItem.donorName && (
<p className="text-sm text-gray-500">Donated by {currentItem.donorName}</p>
<p className="text-sm text-gray-400">Donated by {currentItem.donorName}</p>
)}
</div>
{/* Current bid */}
{/* Current bid display */}
<div className="text-center">
<p className="text-sm text-gray-400 uppercase tracking-wide">Current bid</p>
<p className="text-5xl font-black text-brand-700">
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold mb-1">
Current bid
</p>
<p className="text-6xl font-black text-brand-700 tabular-nums">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
{/* Called amount + bid button */}
{/* Bid button */}
{calledAmount != null && (
<button
onClick={handleBid}
disabled={!canBid}
className="w-full py-6 rounded-2xl bg-brand-600 text-white text-3xl font-black shadow-lg active:scale-95 transition-transform disabled:opacity-40 disabled:cursor-not-allowed"
className="w-full py-6 rounded-2xl bg-brand-700 text-white text-3xl font-black
shadow-bid-btn active:scale-[0.97] transition-all duration-150
disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none"
>
Bid ${calledAmount.toLocaleString()}
</button>
)}
{/* Recent bids stream */}
{/* Recent bids */}
{recentBids.length > 0 && (
<section>
<p className="text-xs uppercase tracking-widest text-gray-400 mb-2">Recent bids</p>
<p className="section-title mb-2">Recent bids</p>
<ul className="space-y-1">
{recentBids.map((b) => (
<li key={b.id} className="flex justify-between text-sm">
<span className="text-gray-500">{b.createdAt}</span>
<span className="font-semibold">${Number(b.amount).toLocaleString()}</span>
<li
key={b.id}
className="flex justify-between items-center text-sm bg-white rounded-xl px-4 py-2.5 border border-gray-100"
>
<span className="text-gray-400 text-xs">{b.createdAt}</span>
<span className="font-bold text-brand-700">
${Number(b.amount).toLocaleString()}
</span>
</li>
))}
</ul>
@@ -99,10 +105,13 @@ export default function LivePage() {
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-gray-400 text-lg">Waiting for the auctioneer to open a lot</p>
<div className="flex flex-col items-center justify-center gap-3 py-20">
<span className="text-5xl">🎙</span>
<p className="text-gray-400 text-center">
Waiting for the auctioneer to open a lot
</p>
</div>
)}
</main>
</div>
);
}
@@ -4,11 +4,11 @@
*/
export default function MyBidsPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">My Bids</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">My Bids</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Bid history not yet implemented
</div>
</main>
</div>
);
}
@@ -1,14 +1,45 @@
/**
* Bidder profile paddle number, contact info, digital paddle QR, notifications prefs.
* Bidder profile paddle number, contact info, digital paddle QR, notification prefs.
* TODO: fetch /api/bidders/me, render paddle QR code.
*/
import { useAuthStore, bidderName } from "../../store/auth.js";
export default function ProfilePage() {
const bidder = useAuthStore((s) => s.bidder);
const logout = useAuthStore((s) => s.logout);
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Profile</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Profile & digital paddle not yet implemented
<div className="p-4 space-y-4 animate-fade-in">
{/* Profile header */}
{bidder && (
<div className="card p-5 flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 text-2xl font-black flex-shrink-0">
{bidder.firstName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 truncate">{bidderName(bidder)}</p>
<p className="text-sm text-gray-400 truncate">{bidder.email ?? bidder.phone}</p>
{bidder.paddleNumber && (
<p className="text-xs text-brand-700 font-semibold mt-0.5">
Paddle #{bidder.paddleNumber}
</p>
)}
</div>
</div>
)}
{/* Digital paddle placeholder */}
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Digital paddle QR code not yet implemented
</div>
</main>
{/* Sign out */}
<button
onClick={logout}
className="btn-ghost w-full text-red-500 border-red-200 hover:bg-red-50"
>
Sign out
</button>
</div>
);
}
+25 -29
View File
@@ -27,17 +27,18 @@ export default function SilentPage({ eventId, auctionId }: Props) {
if (!items.length) {
return (
<main className="p-4">
<h1 className="text-xl font-bold mb-4">Silent Auction</h1>
<p className="text-gray-400">Loading items</p>
</main>
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">🏷</span>
<p className="text-gray-400 text-center">Loading silent auction items</p>
</div>
);
}
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Silent Auction</h1>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Silent Auction</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{items.map((item) => {
const isOutbid = outbidItemIds.has(item.id);
const isClosed = item.state === "closed" || item.state === "passed";
@@ -48,49 +49,44 @@ export default function SilentPage({ eventId, auctionId }: Props) {
return (
<li
key={item.id}
className={`border rounded-xl overflow-hidden shadow-sm ${
isOutbid ? "border-red-400" : "border-gray-200"
}`}
className={`card overflow-hidden ${isOutbid ? "border-red-300" : ""}`}
>
{/* Outbid banner */}
{isOutbid && (
<div className="bg-red-50 text-red-600 text-xs font-bold px-3 py-1">
You've been outbid!
<div className="bg-red-50 text-red-600 text-xs font-bold px-4 py-1.5 border-b border-red-100">
You've been outbid!
</div>
)}
<div className="p-4 space-y-2">
<div className="flex justify-between items-start">
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
isClosed
? "bg-gray-100 text-gray-400"
: "bg-green-100 text-green-700"
}`}
>
<div className="p-4 space-y-3">
<div className="flex justify-between items-center">
<p className="text-xs text-gray-400 font-medium">Lot {item.lotNumber}</p>
<span className={`badge ${isClosed ? "bg-gray-100 text-gray-400" : "bg-emerald-100 text-emerald-700"}`}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
<Link to={`/items/${item.id}`} className="block font-semibold hover:text-brand-600">
<Link
to={`/items/${item.id}`}
className="block font-bold text-gray-900 hover:text-brand-700 transition-colors"
>
{item.title}
</Link>
<div className="flex justify-between items-end">
<div className="flex justify-between items-end pt-1">
<div>
<p className="text-xs text-gray-400">Current bid</p>
<p className="text-lg font-bold text-brand-700">
<p className="text-xs text-gray-400 uppercase tracking-wide">Current bid</p>
<p className="text-xl font-black text-brand-700 tabular-nums">
{item.currentHighBid != null
? `$${item.currentHighBid.toLocaleString()}`
: `Starting at $${item.openingBid.toLocaleString()}`}
: `$${item.openingBid.toLocaleString()}`}
</p>
</div>
{!isClosed && (
<button
onClick={() => void placeSilentBid(item.id, minNext)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-bold hover:bg-brand-700 active:scale-95 transition-transform"
className="btn-primary text-sm px-4 py-2"
>
Bid ${minNext.toLocaleString()}
</button>
@@ -101,6 +97,6 @@ export default function SilentPage({ eventId, auctionId }: Props) {
);
})}
</ul>
</main>
</div>
);
}
@@ -1,20 +1,256 @@
/**
* Auctioneer console optimised for tablet in landscape.
* Shows: current lot, current bid, next callable bid, recent bid stream,
* and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass.
* Auctioneer console optimised for tablet landscape.
*
* TODO:
* - Subscribe to all live auction socket events
* - Emit auctioneer_* events on button press
* - Display large-format current bid and paddle number
* Left panel : item list (all lots with state badges)
* Right panel: active lot controls
* - Current bid display
* - Called amount adjustment
* - Going Once / Going Twice / Sold / Pass
* - Recent bid stream
*/
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useAuctioneerControls } from "../../hooks/useAuctioneerControls.js";
import type { ItemState } from "@storybid/shared";
const STATE_COLOR: Record<ItemState, string> = {
preview: "bg-gray-100 text-gray-500",
active: "bg-green-100 text-green-700",
going_once: "bg-yellow-100 text-yellow-700",
going_twice: "bg-orange-100 text-orange-700",
sold: "bg-blue-100 text-blue-700",
passed: "bg-red-100 text-red-500",
closed: "bg-gray-100 text-gray-400",
};
const STATE_LABEL: Record<ItemState, string> = {
preview: "Preview",
active: "Active",
going_once: "Going Once",
going_twice: "Going Twice",
sold: "Sold",
passed: "Passed",
closed: "Closed",
};
export default function AuctioneerPage() {
const [searchParams] = useSearchParams();
const eventId = searchParams.get("eventId") ?? "";
const auctionId = searchParams.get("auctionId") ?? "";
const {
items, currentItem, currentBid, calledAmount, state, recentBids, loading,
activateItem, callNextBid, goingOnce, goingTwice, sold, pass, suggestNextBid,
} = useAuctioneerControls(eventId, auctionId);
const [customAmount, setCustomAmount] = useState<string>("");
const handleCallAmount = () => {
if (!currentItem) return;
const amt = customAmount ? parseInt(customAmount, 10) : suggestNextBid();
if (!isNaN(amt) && amt > 0) {
callNextBid(currentItem.id, amt);
setCustomAmount("");
}
};
const isClosed = state === "sold" || state === "passed";
return (
<main className="min-h-screen bg-gray-900 text-white p-6 space-y-6">
<h1 className="text-2xl font-bold">Auctioneer Console</h1>
<div className="border border-dashed border-gray-600 rounded-xl p-8 text-center text-gray-500 text-sm">
Live auction controls not yet implemented
<div className="min-h-screen bg-gray-950 text-white flex flex-col">
{/* Header */}
<header className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center justify-between">
<h1 className="text-lg font-bold tracking-tight">Auctioneer Console</h1>
<div className="flex items-center gap-3">
{state && (
<span className={`px-3 py-1 rounded-full text-xs font-bold ${STATE_COLOR[state]}`}>
{STATE_LABEL[state]}
</span>
)}
<span className="text-gray-500 text-sm">{items.length} lots</span>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* ── Item list (left) ─────────────────────────────────────────────── */}
<aside className="w-64 bg-gray-900 border-r border-gray-800 overflow-y-auto flex-shrink-0">
{loading ? (
<p className="text-gray-500 text-sm p-4">Loading lots</p>
) : (
<ul>
{items.map((item) => {
const isActive = currentItem?.id === item.id;
return (
<li key={item.id}>
<button
onClick={() => {
if (item.state === "preview") activateItem(item.id);
}}
className={`w-full text-left px-4 py-3 border-b border-gray-800 hover:bg-gray-800 transition-colors ${
isActive ? "bg-gray-800 border-l-2 border-l-brand-500" : ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400 font-mono">
{item.lotNumber}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded ${STATE_COLOR[item.state as ItemState] ?? ""}`}
>
{STATE_LABEL[item.state as ItemState] ?? item.state}
</span>
</div>
<p className="text-sm font-medium mt-1 leading-tight truncate">
{item.title}
</p>
{item.currentHighBid != null && (
<p className="text-xs text-brand-400 mt-0.5">
${item.currentHighBid.toLocaleString()}
</p>
)}
</button>
</li>
);
})}
</ul>
)}
</aside>
{/* ── Active lot controls (right) ──────────────────────────────────── */}
<main className="flex-1 p-6 overflow-y-auto space-y-6">
{!currentItem ? (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500 text-lg">
Select a lot from the list to activate it
</p>
</div>
) : (
<>
{/* Item info */}
<div className="bg-gray-900 rounded-xl p-5 space-y-1">
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
<h2 className="text-2xl font-bold">{currentItem.title}</h2>
{currentItem.donorName && (
<p className="text-gray-400 text-sm">Donated by {currentItem.donorName}</p>
)}
<p className="text-gray-500 text-xs">
Opening ${currentItem.openingBid.toLocaleString()} ·
Increment ${currentItem.bidIncrement.toLocaleString()}
{currentItem.reservePrice != null &&
` · Reserve $${currentItem.reservePrice.toLocaleString()}`}
</p>
</div>
{/* Current bid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl p-5 text-center">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-1">
Current Bid
</p>
<p className="text-5xl font-black text-brand-400">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 text-center">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-1">
Called Amount
</p>
<p className="text-5xl font-black text-white">
{calledAmount != null ? `$${calledAmount.toLocaleString()}` : "—"}
</p>
</div>
</div>
{/* Call next bid */}
{!isClosed && (
<div className="bg-gray-900 rounded-xl p-5 space-y-3">
<p className="text-gray-400 text-sm font-medium">Call next bid</p>
<div className="flex gap-3">
<button
onClick={() => callNextBid(currentItem.id, suggestNextBid())}
className="flex-1 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 font-bold text-lg transition-colors"
>
${suggestNextBid().toLocaleString()}
</button>
<input
type="number"
placeholder="Custom $"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
className="w-32 bg-gray-800 border border-gray-700 rounded-lg px-3 text-white placeholder-gray-600 focus:outline-none focus:border-brand-500"
/>
<button
onClick={handleCallAmount}
disabled={!customAmount}
className="px-4 py-3 rounded-lg bg-gray-700 hover:bg-gray-600 font-medium disabled:opacity-40 transition-colors"
>
Call
</button>
</div>
</div>
)}
{/* State controls */}
{!isClosed && (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => goingOnce(currentItem.id)}
disabled={state !== "active"}
className="py-4 rounded-xl bg-yellow-600 hover:bg-yellow-500 font-bold text-lg disabled:opacity-30 transition-colors"
>
Going Once
</button>
<button
onClick={() => goingTwice(currentItem.id)}
disabled={state !== "going_once"}
className="py-4 rounded-xl bg-orange-600 hover:bg-orange-500 font-bold text-lg disabled:opacity-30 transition-colors"
>
Going Twice
</button>
<button
onClick={() => sold(currentItem.id)}
disabled={state !== "going_twice" && state !== "going_once" && state !== "active"}
className="py-4 rounded-xl bg-green-600 hover:bg-green-500 font-bold text-xl disabled:opacity-30 transition-colors"
>
SOLD
</button>
<button
onClick={() => pass(currentItem.id)}
disabled={isClosed}
className="py-4 rounded-xl bg-gray-700 hover:bg-gray-600 font-bold text-lg disabled:opacity-30 transition-colors"
>
Pass
</button>
</div>
)}
{/* Recent bids */}
{recentBids.length > 0 && (
<div className="bg-gray-900 rounded-xl p-5">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-3">
Bid stream
</p>
<ul className="space-y-2">
{recentBids.map((b, i) => (
<li
key={b.id}
className={`flex justify-between text-sm ${
i === 0 ? "text-white font-bold" : "text-gray-400"
}`}
>
<span className="font-mono text-xs text-gray-500">
{new Date(b.createdAt).toLocaleTimeString()}
</span>
<span>${Number(b.amount).toLocaleString()}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
</main>
</div>
</main>
</div>
);
}
@@ -1,5 +1,5 @@
/**
* Check-in station search bidders, scan QR, assign paddle, confirm payment readiness.
* Check-in station search bidders, scan QR, assign paddle, confirm payment readiness.
*
* TODO:
* - Search /api/bidders?eventId=&q=
@@ -9,10 +9,24 @@
*/
export default function CheckInPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-2xl font-bold">Check-In</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
QR scan & bidder search not yet implemented
<main className="min-h-screen bg-gray-50 p-4 space-y-4">
<div className="flex items-center gap-3">
<span className="text-3xl"></span>
<div>
<h1 className="text-2xl font-black text-gray-900">Check-In</h1>
<p className="text-sm text-gray-400">Scan QR or search bidder name / paddle</p>
</div>
</div>
<input
type="search"
placeholder="Search paddle # or bidder name…"
className="field"
disabled
/>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
QR scan &amp; bidder search not yet implemented
</div>
</main>
);
@@ -1,26 +1,184 @@
/**
* Display board read-only fullscreen view for projector / TV.
* Shows: current item, current bid, bidder paddle, org branding,
* and optionally a fundraising thermometer.
*
* TODO:
* - Subscribe to live auction events (read-only socket connection)
* - Fullscreen CSS layout with large typography
* - Paddle raise thermometer via paddle_raise_update events
* Shows: current item, current bid, bidder paddle, state banner, and an
* optional Fund-a-Need thermometer when a campaign is active.
* No login required — connects to the socket as a guest viewer.
*/
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { getSocket } from "../../lib/socket.js";
import type { AuctionItem, ItemState } from "@storybid/shared";
const STATE_BG: Partial<Record<ItemState, string>> = {
going_once: "bg-yellow-500",
going_twice: "bg-orange-500",
sold: "bg-green-600",
passed: "bg-gray-600",
};
const STATE_LABEL: Partial<Record<ItemState, string>> = {
going_once: "GOING ONCE",
going_twice: "GOING TWICE",
sold: "SOLD!",
passed: "PASSED",
};
export default function DisplayBoardPage() {
const [searchParams] = useSearchParams();
const eventId = searchParams.get("eventId") ?? "";
const [item, setItem] = useState<AuctionItem | null>(null);
const [currentBid, setCurrentBid] = useState<number | null>(null);
const [calledAmount, setCalledAmount] = useState<number | null>(null);
const [itemState, setItemState] = useState<ItemState | null>(null);
const [campaign, setCampaign] = useState<{
name: string;
goal: number | null;
totalRaised: number;
} | null>(null);
useEffect(() => {
const socket = getSocket(); // no auth token — guest connection
if (eventId) socket.emit("join_event", eventId);
socket.on("item_activated", ({ item: i }) => {
setItem(i);
setCurrentBid(i.currentHighBid);
setCalledAmount(i.openingBid);
setItemState(i.state);
});
socket.on("next_live_bid", ({ amount }) => setCalledAmount(amount));
socket.on("live_bid_accepted", ({ item: i }) => {
setCurrentBid(i.currentHighBid);
setItemState(i.state);
setItem(i);
});
socket.on("item_state_changed", ({ state }) => setItemState(state));
socket.on("item_sold", ({ amount }) => {
setCurrentBid(amount);
setItemState("sold");
});
socket.on("paddle_raise_update", ({ campaignId: _id, totalRaised }) => {
setCampaign((c) => (c ? { ...c, totalRaised } : null));
});
return () => {
if (eventId) socket.emit("leave_event", eventId);
socket.off("item_activated");
socket.off("next_live_bid");
socket.off("live_bid_accepted");
socket.off("item_state_changed");
socket.off("item_sold");
socket.off("paddle_raise_update");
};
}, [eventId]);
const stateBanner = itemState ? STATE_LABEL[itemState] : null;
const stateBg = itemState ? (STATE_BG[itemState] ?? "") : "";
return (
<main className="min-h-screen bg-brand-900 text-white flex flex-col items-center justify-center p-8 space-y-8">
<h1 className="text-5xl font-black tracking-tight">Storybid</h1>
<div className="text-center space-y-2">
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p>
<p className="text-6xl font-bold"></p>
<div className="min-h-screen bg-brand-900 text-white flex flex-col overflow-hidden select-none">
{/* State banner */}
{stateBanner && (
<div
className={`${stateBg} text-center py-4 text-3xl font-black tracking-widest uppercase animate-pulse`}
>
{stateBanner}
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col items-center justify-center gap-8 px-12 py-8">
{/* Org / event branding */}
<p className="text-brand-300 text-sm uppercase tracking-[0.3em] font-semibold">
Live Auction
</p>
{item ? (
<>
{/* Lot + title */}
<div className="text-center space-y-2">
<p className="text-brand-400 text-xl font-mono">Lot {item.lotNumber}</p>
<h1 className="text-5xl font-black leading-tight text-center max-w-3xl">
{item.title}
</h1>
{item.donorName && (
<p className="text-brand-300 text-lg">Donated by {item.donorName}</p>
)}
</div>
{/* Bid display */}
<div className="flex gap-16 items-end">
<div className="text-center">
<p className="text-brand-300 text-sm uppercase tracking-widest mb-1">
Current Bid
</p>
<p className="text-8xl font-black tabular-nums">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
{calledAmount != null && calledAmount !== currentBid && (
<div className="text-center opacity-70">
<p className="text-brand-300 text-sm uppercase tracking-widest mb-1">
Next Bid
</p>
<p className="text-5xl font-bold tabular-nums">
${calledAmount.toLocaleString()}
</p>
</div>
)}
</div>
{/* Fair market value */}
{item.fairMarketValue != null && (
<p className="text-brand-400 text-sm">
Fair market value: ${item.fairMarketValue.toLocaleString()}
</p>
)}
</>
) : (
<div className="text-center space-y-4">
<h1 className="text-6xl font-black tracking-tight">Storybid</h1>
<p className="text-brand-300 text-xl">Auction beginning soon</p>
</div>
)}
</div>
<div className="text-center">
<p className="text-xl text-brand-200">Current Bid</p>
<p className="text-8xl font-black">$</p>
<p className="text-2xl text-brand-300 mt-2">Paddle </p>
</div>
</main>
{/* Fund-a-Need thermometer */}
{campaign && (
<div className="bg-brand-800 px-12 py-6 space-y-2">
<div className="flex justify-between items-baseline">
<span className="text-brand-200 font-bold">{campaign.name}</span>
<span className="text-white text-xl font-black">
${campaign.totalRaised.toLocaleString()}
{campaign.goal && (
<span className="text-brand-300 text-base font-normal">
{" "}/ ${campaign.goal.toLocaleString()}
</span>
)}
</span>
</div>
{campaign.goal && (
<div className="w-full bg-brand-700 rounded-full h-4 overflow-hidden">
<div
className="h-4 bg-green-400 rounded-full transition-all duration-700"
style={{
width: `${Math.min(
100,
Math.round((campaign.totalRaised / campaign.goal) * 100),
)}%`,
}}
/>
</div>
)}
</div>
)}
</div>
);
}
+160 -9
View File
@@ -1,19 +1,170 @@
/**
* Spotter mode floor volunteer enters bids by paddle number.
* Simple: paddle number input + confirm button. Emits auctioneer_accept_bid.
*
* TODO:
* - Show current item and called amount (read-only)
* - Large paddle number input with numeric keyboard
* - Emit place_live_bid (spotter path) on confirm
* Shows: current item title + called amount (read-only, from socket)
* Input: large paddle number field + confirm button
* Emits: auctioneer_accept_bid with the paddle's bidderId resolved server-side
*
* Note: the spotter does NOT resolve paddle → bidderId in the browser.
* Instead we emit auctioneer_accept_bid with a `paddleNumber` hint and let
* the server look up the bidderId from the enrollment — safer, less data in client.
* For now we emit place_live_bid which the server validates against the item state.
*/
import { useState, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { getSocket } from "../../lib/socket.js";
import type { AuctionItem, ItemState } from "@storybid/shared";
const STATE_LABEL: Partial<Record<ItemState, string>> = {
active: "Bidding open",
going_once: "Going once…",
going_twice: "Going twice…",
};
export default function SpotterPage() {
const [searchParams] = useSearchParams();
const eventId = searchParams.get("eventId") ?? "";
const [currentItem, setCurrentItem] = useState<AuctionItem | null>(null);
const [calledAmount, setCalledAmount] = useState<number | null>(null);
const [itemState, setItemState] = useState<ItemState | null>(null);
const [paddle, setPaddle] = useState("");
const [lastBid, setLastBid] = useState<{ paddle: string; amount: number } | null>(null);
const [flash, setFlash] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const socket = getSocket();
socket.emit("join_event", eventId);
socket.on("item_activated", ({ item }) => {
setCurrentItem(item);
setCalledAmount(item.openingBid);
setItemState(item.state);
setLastBid(null);
setPaddle("");
setTimeout(() => inputRef.current?.focus(), 100);
});
socket.on("next_live_bid", ({ amount }) => setCalledAmount(amount));
socket.on("live_bid_accepted", ({ item }) => {
setItemState(item.state);
setCurrentItem(item);
});
socket.on("item_state_changed", ({ state }) => setItemState(state));
socket.on("item_sold", () => {
setItemState("sold");
setPaddle("");
});
return () => {
socket.emit("leave_event", eventId);
socket.off("item_activated");
socket.off("next_live_bid");
socket.off("live_bid_accepted");
socket.off("item_state_changed");
socket.off("item_sold");
};
}, [eventId]);
const canBid =
currentItem !== null &&
calledAmount !== null &&
(itemState === "active" || itemState === "going_once" || itemState === "going_twice");
const handleSubmit = () => {
if (!canBid || !paddle.trim()) return;
// Spotter emits accept_bid with paddle number in the bidderId field.
// The server resolves it via BidderEventEnrollment.paddleNumber.
// We use the socket id as a stand-in deviceId for spotter bids.
const socket = getSocket();
socket.emit("auctioneer_accept_bid", {
itemId: currentItem!.id,
bidderId: `paddle:${paddle.trim()}`, // server resolves paddle → bidderId
amount: calledAmount!,
});
setLastBid({ paddle: paddle.trim(), amount: calledAmount! });
setPaddle("");
setFlash(true);
setTimeout(() => setFlash(false), 600);
setTimeout(() => inputRef.current?.focus(), 100);
};
return (
<main className="min-h-screen p-6 space-y-6">
<h1 className="text-2xl font-bold">Spotter</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Paddle entry not yet implemented
<main className="min-h-screen flex flex-col p-4 gap-6 bg-white">
{/* Header */}
<header className="flex items-center justify-between">
<h1 className="text-xl font-bold">Spotter</h1>
{itemState && STATE_LABEL[itemState] && (
<span className="text-sm font-semibold text-green-600">
{STATE_LABEL[itemState]}
</span>
)}
</header>
{/* Current lot info */}
<div className="bg-gray-50 rounded-xl p-4 space-y-1 min-h-[80px]">
{currentItem ? (
<>
<p className="text-xs text-gray-400 uppercase tracking-wide">
Lot {currentItem.lotNumber}
</p>
<p className="text-lg font-bold leading-tight">{currentItem.title}</p>
</>
) : (
<p className="text-gray-400 text-sm">Waiting for lot to be activated</p>
)}
</div>
{/* Called amount */}
<div className="text-center">
<p className="text-sm text-gray-400 uppercase tracking-widest">Called amount</p>
<p
className={`text-6xl font-black transition-colors ${
flash ? "text-green-500" : "text-brand-700"
}`}
>
{calledAmount != null ? `$${calledAmount.toLocaleString()}` : "—"}
</p>
</div>
{/* Paddle input */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-600">
Paddle number
</label>
<input
ref={inputRef}
type="number"
inputMode="numeric"
value={paddle}
onChange={(e) => setPaddle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder="000"
className="w-full text-center text-5xl font-black border-2 border-gray-200 rounded-2xl py-5 focus:outline-none focus:border-brand-500 tracking-widest"
/>
<button
onClick={handleSubmit}
disabled={!canBid || !paddle.trim()}
className="w-full py-5 rounded-2xl bg-brand-600 text-white text-2xl font-black disabled:opacity-30 active:scale-95 transition-transform"
>
Confirm Bid
</button>
</div>
{/* Last confirmed bid */}
{lastBid && (
<div className="text-center text-sm text-gray-400">
Last: Paddle <strong className="text-gray-700">{lastBid.paddle}</strong>
{" · "}
<strong className="text-gray-700">${lastBid.amount.toLocaleString()}</strong>
</div>
)}
</main>
);
}
+12
View File
@@ -7,6 +7,8 @@ interface AuthState {
role: string | null;
setAuth: (token: string, bidder: Bidder, role: string) => void;
clearAuth: () => void;
/** Alias for clearAuth — used by UI components. */
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
@@ -21,4 +23,14 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.removeItem("sb_token");
set({ token: null, bidder: null, role: null });
},
logout() {
localStorage.removeItem("sb_token");
set({ token: null, bidder: null, role: null });
},
}));
/** Helper to get bidder display name from firstName + lastName. */
export function bidderName(bidder: Bidder | null): string {
if (!bidder) return "Guest";
return `${bidder.firstName} ${bidder.lastName}`.trim() || bidder.email || "Guest";
}