Phase 2 and Demo
This commit is contained in:
+36
-32
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 & live totals — not yet implemented
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user