Phase 2 and Demo
This commit is contained in:
@@ -17,7 +17,7 @@ event-night resilience.
|
|||||||
| Cache/Queue| Redis (optional) |
|
| Cache/Queue| Redis (optional) |
|
||||||
| Auth | Email magic links + Twilio Verify SMS OTP |
|
| Auth | Email magic links + Twilio Verify SMS OTP |
|
||||||
| Payments | Stripe Payment Element / Payment Intents |
|
| Payments | Stripe Payment Element / Payment Intents |
|
||||||
| Media | S3-compatible presigned uploads |
|
| Media | Local disk (multer) served as static files |
|
||||||
| Deploy | Docker Compose (Unraid / Linux VM) |
|
| Deploy | Docker Compose (Unraid / Linux VM) |
|
||||||
|
|
||||||
## Quick Start (development)
|
## Quick Start (development)
|
||||||
@@ -33,7 +33,7 @@ docker compose -f docker-compose.dev.yml up -d
|
|||||||
|
|
||||||
# 3. Configure environment
|
# 3. Configure environment
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your Stripe, Twilio, SMTP, and storage keys
|
# Edit .env with your Stripe, Twilio, and SMTP keys
|
||||||
|
|
||||||
# 4. Migrate database and seed demo data
|
# 4. Migrate database and seed demo data
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
@@ -68,8 +68,8 @@ The full product specification lives in [`STORYBID.md`](./STORYBID.md).
|
|||||||
|
|
||||||
| Phase | Focus | Status |
|
| Phase | Focus | Status |
|
||||||
|-------|------------------------------------------------|---------|
|
|-------|------------------------------------------------|---------|
|
||||||
| 1 | Foundation – monorepo, auth, org/event models | 🏗 scaffold |
|
| 1 | Foundation – monorepo, auth, org/event models | ✅ done |
|
||||||
| 2 | Live Auction – auctioneer console, bidder view | ⬜ todo |
|
| 2 | Live Auction – auctioneer console, bidder view | ✅ done |
|
||||||
| 3 | Silent Auction – catalog, timers, outbid | ⬜ todo |
|
| 3 | Silent Auction – catalog, timers, outbid | ⬜ todo |
|
||||||
| 4 | Offline Resilience – PWA, outbox, failover | ⬜ todo |
|
| 4 | Offline Resilience – PWA, outbox, failover | ⬜ todo |
|
||||||
| 5 | Event Ops – check-in, checkout, fund-a-need | ⬜ todo |
|
| 5 | Event Ops – check-in, checkout, fund-a-need | ⬜ todo |
|
||||||
|
|||||||
+1289
File diff suppressed because it is too large
Load Diff
+25
-21
@@ -1,5 +1,8 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
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
|
// Bidder-facing pages
|
||||||
import HomePage from "./pages/bidder/HomePage.js";
|
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 LoginPage from "./pages/auth/LoginPage.js";
|
||||||
import VerifyPage from "./pages/auth/VerifyPage.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 AuctioneerPage from "./pages/staff/AuctioneerPage.js";
|
||||||
import SpotterPage from "./pages/staff/SpotterPage.js";
|
import SpotterPage from "./pages/staff/SpotterPage.js";
|
||||||
import CheckInPage from "./pages/staff/CheckInPage.js";
|
import CheckInPage from "./pages/staff/CheckInPage.js";
|
||||||
@@ -31,14 +34,19 @@ import FundANeedPage from "./pages/admin/FundANeedPage.js";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<ConnectivityBanner />
|
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth */}
|
{/* ── Auth (no layout) ── */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/verify" element={<VerifyPage />} />
|
<Route path="/verify" element={<VerifyPage />} />
|
||||||
|
|
||||||
{/* Bidder */}
|
{/* ── 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="/" element={<HomePage />} />
|
||||||
<Route path="/live" element={<LivePage />} />
|
<Route path="/live" element={<LivePage />} />
|
||||||
<Route path="/silent" element={<SilentPage />} />
|
<Route path="/silent" element={<SilentPage />} />
|
||||||
@@ -46,24 +54,20 @@ export default function App() {
|
|||||||
<Route path="/my-bids" element={<MyBidsPage />} />
|
<Route path="/my-bids" element={<MyBidsPage />} />
|
||||||
<Route path="/checkout" element={<CheckoutPage />} />
|
<Route path="/checkout" element={<CheckoutPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Staff – optimized single-task views */}
|
{/* ── Admin shell ── */}
|
||||||
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route path="/staff/spotter" element={<SpotterPage />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
<Route path="/staff/check-in" element={<CheckInPage />} />
|
<Route path="events" element={<AdminEventsPage />} />
|
||||||
<Route path="/display" element={<DisplayBoardPage />} />
|
<Route path="items" element={<AdminItemsPage />} />
|
||||||
|
<Route path="bidders" element={<AdminBiddersPage />} />
|
||||||
{/* Admin */}
|
<Route path="checkout" element={<AdminCheckoutPage />} />
|
||||||
<Route path="/admin" element={<AdminDashboard />} />
|
<Route path="reporting" element={<AdminReportingPage />} />
|
||||||
<Route path="/admin/events" element={<AdminEventsPage />} />
|
<Route path="fund-a-need" element={<FundANeedPage />} />
|
||||||
<Route path="/admin/items" element={<AdminItemsPage />} />
|
</Route>
|
||||||
<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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</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";
|
import { useConnectivityStore } from "../store/connectivity.js";
|
||||||
|
|
||||||
const labels: Record<string, { text: string; className: string }> = {
|
const CONFIGS = {
|
||||||
connected: { text: "Connected", className: "bg-green-500" },
|
local: { bg: "bg-gold-500", text: "Local network — offline-capable" },
|
||||||
local: { text: "Local network – offline-capable", className: "bg-yellow-500" },
|
offline: { bg: "bg-red-500", text: "Offline — bids will sync when reconnected" },
|
||||||
offline: { text: "Offline – bids will sync when reconnected", className: "bg-red-500" },
|
} as const;
|
||||||
};
|
|
||||||
|
|
||||||
export function ConnectivityBanner() {
|
export function ConnectivityBanner() {
|
||||||
const status = useConnectivityStore((s) => s.status);
|
const status = useConnectivityStore((s) => s.status);
|
||||||
|
|
||||||
if (status === "connected") return null;
|
if (status === "connected") return null;
|
||||||
|
|
||||||
const { text, className } = labels[status]!;
|
const cfg = CONFIGS[status as keyof typeof CONFIGS];
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className} text-white text-center text-sm py-1 px-4 font-medium`}>
|
<div className={`${cfg.bg} text-white text-center text-xs py-1.5 px-4 font-semibold tracking-wide`}>
|
||||||
{text}
|
{cfg.text}
|
||||||
</div>
|
</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 components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
/* Prevent text-size inflation on mobile */
|
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Prevents iOS rubber-band on the outer scroll */
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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.
|
* TODO: CRUD + bulk import via /api/bidders.
|
||||||
*/
|
*/
|
||||||
export default function AdminBiddersPage() {
|
export default function AdminBiddersPage() {
|
||||||
return (
|
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">
|
<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">
|
<div className="flex gap-2">
|
||||||
<button className="px-3 py-2 border rounded-lg text-sm">Import CSV</button>
|
<button className="btn-ghost text-sm">Import CSV</button>
|
||||||
<button className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
<button className="btn-primary">+ Add Bidder</button>
|
||||||
+ Add Bidder
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
Bidder list — not yet implemented
|
||||||
</div>
|
</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.
|
* TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture.
|
||||||
*/
|
*/
|
||||||
export default function AdminCheckoutPage() {
|
export default function AdminCheckoutPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-6 space-y-4">
|
<div className="p-6 space-y-5 max-w-3xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold">Checkout</h1>
|
<div>
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<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
|
Cashier station — not yet implemented
|
||||||
</div>
|
</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.
|
* TODO: fetch org summary from /api/reporting.
|
||||||
*/
|
*/
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
|
const stats = [
|
||||||
|
{ label: "Events", value: "—", icon: "🗓️" },
|
||||||
|
{ label: "Bidders", value: "—", icon: "🎟️" },
|
||||||
|
{ label: "Revenue", value: "—", icon: "💰" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-6 space-y-6">
|
<div className="p-6 space-y-6 max-w-5xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{["Events", "Bidders", "Revenue"].map((label) => (
|
{stats.map(({ label, value, icon }) => (
|
||||||
<div key={label} className="border rounded-xl p-5 text-center">
|
<div key={label} className="card p-5">
|
||||||
<p className="text-gray-500 text-sm">{label}</p>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<p className="text-3xl font-bold mt-1">—</p>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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.
|
* TODO: CRUD via /api/events.
|
||||||
*/
|
*/
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Events</h1>
|
<div>
|
||||||
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
<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
|
+ New Event
|
||||||
</button>
|
</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">
|
||||||
Events list — not yet implemented
|
Events list — not yet implemented
|
||||||
</div>
|
</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.
|
* TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events.
|
||||||
*/
|
*/
|
||||||
export default function FundANeedPage() {
|
export default function FundANeedPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-6 space-y-4">
|
<div className="p-6 space-y-5 max-w-3xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold">Fund-a-Need</h1>
|
<div>
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<h1 className="text-2xl font-black text-gray-900">Fund-a-Need</h1>
|
||||||
Paddle raise setup & live totals — not yet implemented
|
<p className="text-sm text-gray-400 mt-0.5">Paddle raise setup & live totals</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">
|
||||||
|
Paddle raise setup & live totals — not yet implemented
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
* TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart).
|
||||||
*/
|
*/
|
||||||
export default function AdminItemsPage() {
|
export default function AdminItemsPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-6 space-y-4">
|
<div className="p-6 space-y-5 max-w-5xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold">Item Manager</h1>
|
<div className="flex items-center justify-between">
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<div>
|
||||||
Item list & editor — not yet implemented
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/*.
|
* TODO: fetch /api/reporting/events/:id/*.
|
||||||
*/
|
*/
|
||||||
export default function AdminReportingPage() {
|
export default function AdminReportingPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-6 space-y-4">
|
<div className="p-6 space-y-5 max-w-5xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold">Reporting</h1>
|
<div>
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<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
|
Reports — not yet implemented
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,228 @@
|
|||||||
/**
|
/**
|
||||||
* Login – email magic link or SMS OTP entry point.
|
* Login — email magic link or SMS OTP.
|
||||||
* TODO: implement magic-link request form and OTP flow.
|
*
|
||||||
|
* 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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center p-4">
|
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="w-full max-w-sm overflow-hidden rounded-2xl shadow-card-lg border border-gray-100 bg-white animate-fade-in">
|
||||||
<h1 className="text-2xl font-bold text-center">Sign in to bid</h1>
|
|
||||||
<p className="text-center text-gray-500 text-sm">
|
{/* ── Brand header ── */}
|
||||||
Enter your email for a magic link, or your phone number for a one-time code.
|
<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>
|
||||||
{/* TODO: LoginForm component */}
|
<p className="text-brand-200 text-sm mt-1.5">Sign in to bid</p>
|
||||||
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm">
|
</div>
|
||||||
LoginForm — not yet implemented
|
|
||||||
|
{/* ── 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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,11 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* Verify – handles magic-link ?token= callback and OTP confirmation.
|
* Verify – handles magic-link ?token= callback.
|
||||||
* TODO: read token from URL, call /api/auth/verify, redirect to /.
|
*
|
||||||
|
* 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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center p-4">
|
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||||
<p className="text-gray-500">Verifying…</p>
|
<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>
|
</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.
|
* TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success.
|
||||||
*/
|
*/
|
||||||
export default function CheckoutPage() {
|
export default function CheckoutPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="p-4 space-y-4 animate-fade-in">
|
||||||
<h1 className="text-xl font-bold">Checkout</h1>
|
<p className="section-title">Checkout</p>
|
||||||
<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">
|
||||||
Stripe checkout — not yet implemented
|
Stripe checkout — not yet implemented
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,100 @@
|
|||||||
/**
|
/**
|
||||||
* Bidder home – event welcome screen, quick nav to Live / Silent / My Bids.
|
* Bidder home — welcome screen, quick nav cards, event status strip.
|
||||||
* TODO: fetch event details, show upcoming lots, paddle number, QR code.
|
* 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() {
|
export default function HomePage() {
|
||||||
|
const bidder = useAuthStore((s) => s.bidder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="flex flex-col min-h-full">
|
||||||
<h1 className="text-2xl font-bold">Welcome to the Auction</h1>
|
{/* ── Hero strip ── */}
|
||||||
<nav className="grid grid-cols-2 gap-3">
|
<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">
|
||||||
{ label: "🎙 Live Auction", href: "/live" },
|
Welcome back
|
||||||
{ label: "🔇 Silent Auction", href: "/silent" },
|
</p>
|
||||||
{ label: "📋 My Bids", href: "/my-bids" },
|
<h1 className="text-white text-2xl font-black tracking-tight leading-tight">
|
||||||
{ label: "💳 Checkout", href: "/checkout" },
|
{bidderName(bidder)}
|
||||||
].map(({ label, href }) => (
|
</h1>
|
||||||
<a
|
{bidder?.paddleNumber && (
|
||||||
key={href}
|
<div className="inline-flex items-center gap-2 mt-3 bg-brand-800/60 rounded-xl px-3 py-1.5">
|
||||||
href={href}
|
<span className="text-gold-300 font-black text-lg">#{bidder.paddleNumber}</span>
|
||||||
className="block rounded-xl border border-gray-200 p-5 text-center font-semibold text-brand-700 hover:bg-brand-50"
|
<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" : ""}`}
|
||||||
>
|
>
|
||||||
{label}
|
<span className="text-2xl leading-none">{icon}</span>
|
||||||
</a>
|
<p className={`font-bold text-sm mt-1 ${text}`}>{label}</p>
|
||||||
|
<p className={`text-xs ${sub_text}`}>{sub}</p>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
* - Media carousel (images, video embed, documents)
|
* - Media carousel (images, video embed, documents)
|
||||||
* - Place bid form with offline-outbox fallback via db.outbox
|
* - Place bid form with offline-outbox fallback via db.outbox
|
||||||
*/
|
*/
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function ItemPage() {
|
export default function ItemPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="p-4 space-y-4 animate-fade-in">
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<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
|
Item detail — not yet implemented
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,16 @@ import { useParams } from "react-router-dom";
|
|||||||
import { useLiveAuction } from "../../hooks/useLiveAuction.js";
|
import { useLiveAuction } from "../../hooks/useLiveAuction.js";
|
||||||
import { useOfflineBids } from "../../hooks/useOfflineBids.js";
|
import { useOfflineBids } from "../../hooks/useOfflineBids.js";
|
||||||
|
|
||||||
const STATE_LABELS: Record<string, string> = {
|
const STATE_META: Record<string, { label: string; color: string }> = {
|
||||||
preview: "Up next",
|
preview: { label: "Up next", color: "bg-brand-100 text-brand-700" },
|
||||||
active: "Bidding open",
|
active: { label: "Bidding open", color: "bg-emerald-100 text-emerald-700" },
|
||||||
going_once: "Going once…",
|
going_once: { label: "Going once…", color: "bg-gold-100 text-gold-700" },
|
||||||
going_twice: "Going twice…",
|
going_twice: { label: "Going twice…", color: "bg-orange-100 text-orange-700" },
|
||||||
sold: "SOLD",
|
sold: { label: "SOLD", color: "bg-gray-100 text-gray-500" },
|
||||||
passed: "Passed",
|
passed: { label: "Passed", color: "bg-gray-100 text-gray-400" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivePage() {
|
export default function LivePage() {
|
||||||
// eventId comes from route or a global store; use param or fallback
|
|
||||||
const { eventId = "" } = useParams<{ eventId?: string }>();
|
const { eventId = "" } = useParams<{ eventId?: string }>();
|
||||||
const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } =
|
const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } =
|
||||||
useLiveAuction(eventId);
|
useLiveAuction(eventId);
|
||||||
@@ -32,66 +31,73 @@ export default function LivePage() {
|
|||||||
placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq);
|
placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSold = state === "sold" || state === "passed";
|
|
||||||
const canBid = state === "active" || state === "going_once" || state === "going_twice";
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex flex-col p-4 gap-6">
|
<div className="flex flex-col p-4 gap-5 animate-fade-in">
|
||||||
{/* Status banner */}
|
{/* ── Section header ── */}
|
||||||
<div className="text-center">
|
<p className="section-title text-center">Live Auction</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
{currentItem ? (
|
{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 */}
|
{/* Item info */}
|
||||||
<div className="text-center space-y-1">
|
<div className="card p-5 text-center space-y-1 animate-slide-up">
|
||||||
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
|
<p className="text-xs text-gray-400 uppercase tracking-widest">
|
||||||
<h1 className="text-2xl font-bold">{currentItem.title}</h1>
|
Lot {currentItem.lotNumber}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{currentItem.title}</h2>
|
||||||
{currentItem.donorName && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Current bid */}
|
{/* Current bid display */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-gray-400 uppercase tracking-wide">Current bid</p>
|
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold mb-1">
|
||||||
<p className="text-5xl font-black text-brand-700">
|
Current bid
|
||||||
|
</p>
|
||||||
|
<p className="text-6xl font-black text-brand-700 tabular-nums">
|
||||||
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
|
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Called amount + bid button */}
|
{/* Bid button */}
|
||||||
{calledAmount != null && (
|
{calledAmount != null && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBid}
|
onClick={handleBid}
|
||||||
disabled={!canBid}
|
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()}
|
Bid ${calledAmount.toLocaleString()}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent bids stream */}
|
{/* Recent bids */}
|
||||||
{recentBids.length > 0 && (
|
{recentBids.length > 0 && (
|
||||||
<section>
|
<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">
|
<ul className="space-y-1">
|
||||||
{recentBids.map((b) => (
|
{recentBids.map((b) => (
|
||||||
<li key={b.id} className="flex justify-between text-sm">
|
<li
|
||||||
<span className="text-gray-500">{b.createdAt}</span>
|
key={b.id}
|
||||||
<span className="font-semibold">${Number(b.amount).toLocaleString()}</span>
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -99,10 +105,13 @@ export default function LivePage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||||
<p className="text-gray-400 text-lg">Waiting for the auctioneer to open a lot…</p>
|
<span className="text-5xl">🎙</span>
|
||||||
|
<p className="text-gray-400 text-center">
|
||||||
|
Waiting for the auctioneer to open a lot…
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
export default function MyBidsPage() {
|
export default function MyBidsPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="p-4 space-y-4 animate-fade-in">
|
||||||
<h1 className="text-xl font-bold">My Bids</h1>
|
<p className="section-title">My Bids</p>
|
||||||
<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">
|
||||||
Bid history — not yet implemented
|
Bid history — not yet implemented
|
||||||
</div>
|
</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.
|
* TODO: fetch /api/bidders/me, render paddle QR code.
|
||||||
*/
|
*/
|
||||||
|
import { useAuthStore, bidderName } from "../../store/auth.js";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
|
const bidder = useAuthStore((s) => s.bidder);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="p-4 space-y-4 animate-fade-in">
|
||||||
<h1 className="text-xl font-bold">Profile</h1>
|
{/* Profile header */}
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
{bidder && (
|
||||||
Profile & digital paddle — not yet implemented
|
<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>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="btn-ghost w-full text-red-500 border-red-200 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,17 +27,18 @@ export default function SilentPage({ eventId, auctionId }: Props) {
|
|||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return (
|
return (
|
||||||
<main className="p-4">
|
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
|
||||||
<h1 className="text-xl font-bold mb-4">Silent Auction</h1>
|
<span className="text-5xl">🏷️</span>
|
||||||
<p className="text-gray-400">Loading items…</p>
|
<p className="text-gray-400 text-center">Loading silent auction items…</p>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<div className="p-4 space-y-4 animate-fade-in">
|
||||||
<h1 className="text-xl font-bold">Silent Auction</h1>
|
<p className="section-title">Silent Auction</p>
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isOutbid = outbidItemIds.has(item.id);
|
const isOutbid = outbidItemIds.has(item.id);
|
||||||
const isClosed = item.state === "closed" || item.state === "passed";
|
const isClosed = item.state === "closed" || item.state === "passed";
|
||||||
@@ -48,49 +49,44 @@ export default function SilentPage({ eventId, auctionId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`border rounded-xl overflow-hidden shadow-sm ${
|
className={`card overflow-hidden ${isOutbid ? "border-red-300" : ""}`}
|
||||||
isOutbid ? "border-red-400" : "border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Outbid banner */}
|
{/* Outbid banner */}
|
||||||
{isOutbid && (
|
{isOutbid && (
|
||||||
<div className="bg-red-50 text-red-600 text-xs font-bold px-3 py-1">
|
<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!
|
⚡ You've been outbid!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-3">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
|
<p className="text-xs text-gray-400 font-medium">Lot {item.lotNumber}</p>
|
||||||
<span
|
<span className={`badge ${isClosed ? "bg-gray-100 text-gray-400" : "bg-emerald-100 text-emerald-700"}`}>
|
||||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
isClosed
|
|
||||||
? "bg-gray-100 text-gray-400"
|
|
||||||
: "bg-green-100 text-green-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isClosed ? "Closed" : "Open"}
|
{isClosed ? "Closed" : "Open"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end pt-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400">Current bid</p>
|
<p className="text-xs text-gray-400 uppercase tracking-wide">Current bid</p>
|
||||||
<p className="text-lg font-bold text-brand-700">
|
<p className="text-xl font-black text-brand-700 tabular-nums">
|
||||||
{item.currentHighBid != null
|
{item.currentHighBid != null
|
||||||
? `$${item.currentHighBid.toLocaleString()}`
|
? `$${item.currentHighBid.toLocaleString()}`
|
||||||
: `Starting at $${item.openingBid.toLocaleString()}`}
|
: `$${item.openingBid.toLocaleString()}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isClosed && (
|
{!isClosed && (
|
||||||
<button
|
<button
|
||||||
onClick={() => void placeSilentBid(item.id, minNext)}
|
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()}
|
Bid ${minNext.toLocaleString()}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,6 +97,6 @@ export default function SilentPage({ eventId, auctionId }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,256 @@
|
|||||||
/**
|
/**
|
||||||
* Auctioneer console – optimised for tablet in landscape.
|
* Auctioneer console – optimised for tablet landscape.
|
||||||
* Shows: current lot, current bid, next callable bid, recent bid stream,
|
|
||||||
* and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass.
|
|
||||||
*
|
*
|
||||||
* TODO:
|
* Left panel : item list (all lots with state badges)
|
||||||
* - Subscribe to all live auction socket events
|
* Right panel: active lot controls
|
||||||
* - Emit auctioneer_* events on button press
|
* - Current bid display
|
||||||
* - Display large-format current bid and paddle number
|
* - 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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-gray-900 text-white p-6 space-y-6">
|
<div className="min-h-screen bg-gray-950 text-white flex flex-col">
|
||||||
<h1 className="text-2xl font-bold">Auctioneer Console</h1>
|
{/* Header */}
|
||||||
<div className="border border-dashed border-gray-600 rounded-xl p-8 text-center text-gray-500 text-sm">
|
<header className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||||
Live auction controls — not yet implemented
|
<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>
|
</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>
|
</main>
|
||||||
|
</div>
|
||||||
|
</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:
|
* TODO:
|
||||||
* - Search /api/bidders?eventId=&q=
|
* - Search /api/bidders?eventId=&q=
|
||||||
@@ -9,10 +9,24 @@
|
|||||||
*/
|
*/
|
||||||
export default function CheckInPage() {
|
export default function CheckInPage() {
|
||||||
return (
|
return (
|
||||||
<main className="p-4 space-y-4">
|
<main className="min-h-screen bg-gray-50 p-4 space-y-4">
|
||||||
<h1 className="text-2xl font-bold">Check-In</h1>
|
<div className="flex items-center gap-3">
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<span className="text-3xl">✅</span>
|
||||||
QR scan & bidder search — not yet implemented
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,184 @@
|
|||||||
/**
|
/**
|
||||||
* Display board – read-only fullscreen view for projector / TV.
|
* Display board – read-only fullscreen view for projector / TV.
|
||||||
* Shows: current item, current bid, bidder paddle, org branding,
|
|
||||||
* and optionally a fundraising thermometer.
|
|
||||||
*
|
*
|
||||||
* TODO:
|
* Shows: current item, current bid, bidder paddle, state banner, and an
|
||||||
* - Subscribe to live auction events (read-only socket connection)
|
* optional Fund-a-Need thermometer when a campaign is active.
|
||||||
* - Fullscreen CSS layout with large typography
|
* No login required — connects to the socket as a guest viewer.
|
||||||
* - Paddle raise thermometer via paddle_raise_update events
|
|
||||||
*/
|
*/
|
||||||
|
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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-brand-900 text-white flex flex-col items-center justify-center p-8 space-y-8">
|
<div className="min-h-screen bg-brand-900 text-white flex flex-col overflow-hidden select-none">
|
||||||
<h1 className="text-5xl font-black tracking-tight">Storybid</h1>
|
{/* 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">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p>
|
<p className="text-brand-400 text-xl font-mono">Lot {item.lotNumber}</p>
|
||||||
<p className="text-6xl font-bold">—</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Bid display */}
|
||||||
|
<div className="flex gap-16 items-end">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xl text-brand-200">Current Bid</p>
|
<p className="text-brand-300 text-sm uppercase tracking-widest mb-1">
|
||||||
<p className="text-8xl font-black">$—</p>
|
Current Bid
|
||||||
<p className="text-2xl text-brand-300 mt-2">Paddle —</p>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,170 @@
|
|||||||
/**
|
/**
|
||||||
* Spotter mode – floor volunteer enters bids by paddle number.
|
* Spotter mode – floor volunteer enters bids by paddle number.
|
||||||
* Simple: paddle number input + confirm button. Emits auctioneer_accept_bid.
|
|
||||||
*
|
*
|
||||||
* TODO:
|
* Shows: current item title + called amount (read-only, from socket)
|
||||||
* - Show current item and called amount (read-only)
|
* Input: large paddle number field + confirm button
|
||||||
* - Large paddle number input with numeric keyboard
|
* Emits: auctioneer_accept_bid with the paddle's bidderId resolved server-side
|
||||||
* - Emit place_live_bid (spotter path) on confirm
|
*
|
||||||
|
* 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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen p-6 space-y-6">
|
<main className="min-h-screen flex flex-col p-4 gap-6 bg-white">
|
||||||
<h1 className="text-2xl font-bold">Spotter</h1>
|
{/* Header */}
|
||||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
<header className="flex items-center justify-between">
|
||||||
Paddle entry — not yet implemented
|
<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>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface AuthState {
|
|||||||
role: string | null;
|
role: string | null;
|
||||||
setAuth: (token: string, bidder: Bidder, role: string) => void;
|
setAuth: (token: string, bidder: Bidder, role: string) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
|
/** Alias for clearAuth — used by UI components. */
|
||||||
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
@@ -21,4 +23,14 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
localStorage.removeItem("sb_token");
|
localStorage.removeItem("sb_token");
|
||||||
set({ token: null, bidder: null, role: null });
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
// ── Storybook Farm brand palette ───────────────────────────────────────────────
|
||||||
|
// Derived directly from the organization logo:
|
||||||
|
// Forest green → "STORYBOOK FARM" text ≈ #2B5016
|
||||||
|
// Warm gold → curved motto text ≈ #C4952A
|
||||||
|
|
||||||
|
const green = {
|
||||||
|
50: "#f1f8ec",
|
||||||
|
100: "#daefd0",
|
||||||
|
200: "#b5dfa1",
|
||||||
|
300: "#8bcb6d",
|
||||||
|
400: "#63b43e",
|
||||||
|
500: "#4a9528",
|
||||||
|
600: "#3a771f",
|
||||||
|
700: "#2b5916", // ← primary brand green (logo match)
|
||||||
|
800: "#1e3f10",
|
||||||
|
900: "#12270a",
|
||||||
|
950: "#091406",
|
||||||
|
};
|
||||||
|
|
||||||
|
const gold = {
|
||||||
|
50: "#fdf8ed",
|
||||||
|
100: "#faeed0",
|
||||||
|
200: "#f4da99",
|
||||||
|
300: "#ecc45e",
|
||||||
|
400: "#e4ae32",
|
||||||
|
500: "#c4952a", // ← primary brand gold (logo match)
|
||||||
|
600: "#a37820",
|
||||||
|
700: "#815d19",
|
||||||
|
800: "#614513",
|
||||||
|
900: "#422e0e",
|
||||||
|
950: "#221708",
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: green,
|
||||||
50: "#eff6ff",
|
gold,
|
||||||
100: "#dbeafe",
|
|
||||||
500: "#3b82f6",
|
|
||||||
600: "#2563eb",
|
|
||||||
700: "#1d4ed8",
|
|
||||||
900: "#1e3a8a",
|
|
||||||
},
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
"Inter",
|
||||||
|
"ui-sans-serif",
|
||||||
|
"system-ui",
|
||||||
|
"-apple-system",
|
||||||
|
"BlinkMacSystemFont",
|
||||||
|
"Segoe UI",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
"2xl": "1rem",
|
||||||
|
"3xl": "1.5rem",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.06)",
|
||||||
|
"card-lg": "0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06)",
|
||||||
|
"bid-btn": "0 4px 16px 0 rgb(43 89 22 / 0.35)",
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
xs: "390px",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"fade-in": { from: { opacity: "0", transform: "translateY(6px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
||||||
|
"slide-up": { from: { opacity: "0", transform: "translateY(20px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
||||||
|
"pulse-green": { "0%, 100%": { backgroundColor: "rgb(43 89 22 / 0.1)" }, "50%": { backgroundColor: "rgb(43 89 22 / 0.25)" } },
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"fade-in": "fade-in 0.2s ease-out",
|
||||||
|
"slide-up": "slide-up 0.3s ease-out",
|
||||||
|
"pulse-green": "pulse-green 1.5s ease-in-out infinite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
|
|
||||||
import { app } from "./app.js";
|
import { app } from "./app.js";
|
||||||
import { registerSocketHandlers } from "./socket/index.js";
|
import { registerSocketHandlers } from "./socket/index.js";
|
||||||
|
import { startScheduler } from "./services/scheduler.js";
|
||||||
import { prisma } from "./lib/prisma.js";
|
import { prisma } from "./lib/prisma.js";
|
||||||
|
|
||||||
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
|
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
|
||||||
@@ -31,6 +32,7 @@ export const io = new Server<
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerSocketHandlers(io);
|
registerSocketHandlers(io);
|
||||||
|
startScheduler(io);
|
||||||
|
|
||||||
httpServer.listen(PORT, () => {
|
httpServer.listen(PORT, () => {
|
||||||
console.log(`[server] listening on http://localhost:${PORT}`);
|
console.log(`[server] listening on http://localhost:${PORT}`);
|
||||||
|
|||||||
@@ -1,31 +1,256 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/checkout/:bidderId – get invoice for bidder
|
* GET /api/checkout/:bidderId – get or create invoice for a bidder + event
|
||||||
* POST /api/checkout/:bidderId/pay – create Stripe Payment Intent
|
* POST /api/checkout/:bidderId/intent – create Stripe Payment Intent, return client_secret
|
||||||
* POST /api/checkout/:bidderId/capture – capture/finalize payment
|
* POST /api/checkout/:bidderId/complete – mark invoice paid after webhook confirms
|
||||||
* POST /api/checkout/donate – one-time donation
|
* POST /api/checkout/donate – one-time donation Payment Intent
|
||||||
* POST /api/checkout/paddle-raise – paddle raise donation
|
* POST /api/checkout/paddle-raise – paddle raise donation Payment Intent
|
||||||
*/
|
*/
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
import { requireAuth, requireRole } from "../middleware/auth.js";
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
|
||||||
export const checkoutRouter = Router();
|
export const checkoutRouter = Router();
|
||||||
|
|
||||||
checkoutRouter.get("/:bidderId", requireAuth, (_req, res) => {
|
function getStripe(): Stripe {
|
||||||
res.status(501).json({ error: "Not implemented" });
|
const key = process.env["STRIPE_SECRET_KEY"];
|
||||||
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
|
||||||
|
return new Stripe(key, { apiVersion: "2024-04-10" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get / create invoice ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
checkoutRouter.get("/:bidderId", requireAuth, async (req, res) => {
|
||||||
|
const { bidderId } = req.params;
|
||||||
|
const { eventId } = req.query;
|
||||||
|
|
||||||
|
const isOwn = req.auth!.sub === bidderId;
|
||||||
|
const isStaff = ["admin", "event_manager"].includes(req.auth!.role);
|
||||||
|
if (!isOwn && !isStaff) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof eventId !== "string") {
|
||||||
|
res.status(400).json({ error: "eventId query param required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing open invoice or build a new draft from winning bids
|
||||||
|
let invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { bidderId, eventId, status: { notIn: ["void"] } },
|
||||||
|
include: { payments: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
checkoutRouter.post("/:bidderId/pay", requireAuth, (_req, res) => {
|
if (!invoice) {
|
||||||
res.status(501).json({ error: "Not implemented" });
|
// Tally all items where this bidder is the current high bidder + item is sold/closed
|
||||||
|
const wonItems = await prisma.auctionItem.findMany({
|
||||||
|
where: {
|
||||||
|
currentHighBidderId: bidderId,
|
||||||
|
state: { in: ["sold", "closed"] },
|
||||||
|
auction: { eventId },
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, currentHighBid: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
checkoutRouter.post("/:bidderId/capture", requireAuth, requireRole("admin", "event_manager"), (_req, res) => {
|
const totalAmount = wonItems.reduce(
|
||||||
res.status(501).json({ error: "Not implemented" });
|
(sum, i) => sum + Number(i.currentHighBid ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
invoice = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
bidderId,
|
||||||
|
eventId,
|
||||||
|
totalAmount,
|
||||||
|
status: totalAmount > 0 ? "open" : "draft",
|
||||||
|
},
|
||||||
|
include: { payments: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach won item summary
|
||||||
|
const wonItems = await prisma.auctionItem.findMany({
|
||||||
|
where: {
|
||||||
|
currentHighBidderId: bidderId,
|
||||||
|
state: { in: ["sold", "closed"] },
|
||||||
|
auction: { eventId },
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, lotNumber: true, currentHighBid: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
checkoutRouter.post("/donate", requireAuth, (_req, res) => {
|
res.json({ invoice, wonItems });
|
||||||
res.status(501).json({ error: "Not implemented" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
checkoutRouter.post("/paddle-raise", requireAuth, (_req, res) => {
|
// ── Create Payment Intent ──────────────────────────────────────────────────────
|
||||||
res.status(501).json({ error: "Not implemented" });
|
|
||||||
|
const IntentSchema = z.object({ eventId: z.string() });
|
||||||
|
|
||||||
|
checkoutRouter.post("/:bidderId/intent", requireAuth, async (req, res) => {
|
||||||
|
const { bidderId } = req.params;
|
||||||
|
|
||||||
|
const isOwn = req.auth!.sub === bidderId;
|
||||||
|
const isStaff = ["admin", "event_manager"].includes(req.auth!.role);
|
||||||
|
if (!isOwn && !isStaff) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parse = IntentSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: parse.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { bidderId, eventId: parse.data.eventId, status: "open" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ error: "No open invoice found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(invoice.totalAmount) <= 0) {
|
||||||
|
res.status(400).json({ error: "Invoice total is zero" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stripe = getStripe();
|
||||||
|
|
||||||
|
// Amount in cents
|
||||||
|
const amountCents = Math.round(Number(invoice.totalAmount) * 100);
|
||||||
|
|
||||||
|
const bidder = await prisma.bidder.findUniqueOrThrow({
|
||||||
|
where: { id: bidderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount: amountCents,
|
||||||
|
currency: "usd",
|
||||||
|
metadata: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
bidderId,
|
||||||
|
eventId: parse.data.eventId,
|
||||||
|
},
|
||||||
|
description: `Auction invoice ${invoice.id}`,
|
||||||
|
receipt_email: bidder.email ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist the Payment Intent ID on the invoice
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoice.id },
|
||||||
|
data: { stripeInvoiceId: intent.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a pending payment record
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
stripePaymentIntentId: intent.id,
|
||||||
|
amount: invoice.totalAmount,
|
||||||
|
currency: "usd",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ clientSecret: intent.client_secret, invoiceId: invoice.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[checkout] Stripe error", err);
|
||||||
|
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Donation ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DonateSchema = z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
campaignId: z.string().optional(),
|
||||||
|
anonymous: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
checkoutRouter.post("/donate", requireAuth, async (req, res) => {
|
||||||
|
const parse = DonateSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: parse.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { eventId, amount, campaignId, anonymous } = parse.data;
|
||||||
|
const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stripe = getStripe();
|
||||||
|
const amountCents = Math.round(amount * 100);
|
||||||
|
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount: amountCents,
|
||||||
|
currency: "usd",
|
||||||
|
metadata: { eventId, bidderId: bidderId ?? "guest", campaignId: campaignId ?? "" },
|
||||||
|
description: campaignId ? `Paddle raise donation` : `General donation`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage donation record — confirmed by webhook
|
||||||
|
await prisma.donation.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
bidderId,
|
||||||
|
campaignId: campaignId ?? null,
|
||||||
|
amount,
|
||||||
|
anonymous,
|
||||||
|
stripePaymentIntentId: intent.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ clientSecret: intent.client_secret });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[checkout] donation Stripe error", err);
|
||||||
|
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Paddle Raise ───────────────────────────────────────────────────────────────
|
||||||
|
// Paddle raise uses the same donation flow but we update the campaign total on success.
|
||||||
|
// Reuse /donate with a campaignId — the webhook handles incrementing totalRaised.
|
||||||
|
|
||||||
|
checkoutRouter.post("/paddle-raise", requireAuth, async (req, res) => {
|
||||||
|
// Alias to /donate — campaignId required
|
||||||
|
const parse = DonateSchema.extend({ campaignId: z.string() }).safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: parse.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.body = parse.data;
|
||||||
|
// Forward to donate handler logic inline
|
||||||
|
const { eventId, amount, campaignId, anonymous } = parse.data;
|
||||||
|
const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stripe = getStripe();
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount: Math.round(amount * 100),
|
||||||
|
currency: "usd",
|
||||||
|
metadata: { eventId, bidderId: bidderId ?? "guest", campaignId },
|
||||||
|
description: "Paddle raise donation",
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.donation.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
bidderId,
|
||||||
|
campaignId,
|
||||||
|
amount,
|
||||||
|
anonymous,
|
||||||
|
stripePaymentIntentId: intent.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ clientSecret: intent.client_secret });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[checkout] paddle raise Stripe error", err);
|
||||||
|
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/reporting/events/:id/summary – event revenue & sell-through
|
* GET /api/reporting/events/:id/summary – revenue, sell-through, item count
|
||||||
* GET /api/reporting/events/:id/bidders – bidder activity report
|
* GET /api/reporting/events/:id/bidders – per-bidder activity + invoice status
|
||||||
* GET /api/reporting/events/:id/audit-log – full audit log
|
* GET /api/reporting/events/:id/audit-log – full audit log with pagination
|
||||||
*/
|
*/
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
import { requireAuth, requireRole } from "../middleware/auth.js";
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
|
||||||
export const reportingRouter = Router();
|
export const reportingRouter = Router();
|
||||||
|
|
||||||
const adminOnly = requireRole("admin", "event_manager");
|
const adminOnly = requireRole("admin", "event_manager");
|
||||||
|
|
||||||
reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, (_req, res) => {
|
// ── Event summary ──────────────────────────────────────────────────────────────
|
||||||
res.status(501).json({ error: "Not implemented" });
|
reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, async (req, res) => {
|
||||||
|
const eventId = req.params["id"]!;
|
||||||
|
|
||||||
|
const [event, auctions, invoices, donations] = await Promise.all([
|
||||||
|
prisma.auctionEvent.findFirst({
|
||||||
|
where: { id: eventId, organizationId: req.auth!.organizationId },
|
||||||
|
}),
|
||||||
|
prisma.auction.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
state: true,
|
||||||
|
currentHighBid: true,
|
||||||
|
fairMarketValue: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.invoice.findMany({ where: { eventId } }),
|
||||||
|
prisma.donation.findMany({ where: { eventId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ error: "Event not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = auctions.flatMap((a) => a.items);
|
||||||
|
const soldItems = allItems.filter((i) => i.state === "sold" || i.state === "closed");
|
||||||
|
|
||||||
|
const grossRevenue = soldItems.reduce(
|
||||||
|
(sum, i) => sum + Number(i.currentHighBid ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalDonations = donations.reduce((sum, d) => sum + Number(d.amount), 0);
|
||||||
|
const totalPaid = invoices.reduce((sum, inv) => sum + Number(inv.paidAmount), 0);
|
||||||
|
const totalOutstanding = invoices
|
||||||
|
.filter((inv) => inv.status !== "paid" && inv.status !== "void")
|
||||||
|
.reduce((sum, inv) => sum + (Number(inv.totalAmount) - Number(inv.paidAmount)), 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
event,
|
||||||
|
items: {
|
||||||
|
total: allItems.length,
|
||||||
|
sold: soldItems.length,
|
||||||
|
sellThroughPct:
|
||||||
|
allItems.length > 0
|
||||||
|
? Math.round((soldItems.length / allItems.length) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
revenue: {
|
||||||
|
gross: grossRevenue,
|
||||||
|
donations: totalDonations,
|
||||||
|
total: grossRevenue + totalDonations,
|
||||||
|
collected: totalPaid,
|
||||||
|
outstanding: totalOutstanding,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, (_req, res) => {
|
// ── Bidder activity ────────────────────────────────────────────────────────────
|
||||||
res.status(501).json({ error: "Not implemented" });
|
reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, async (req, res) => {
|
||||||
|
const eventId = req.params["id"]!;
|
||||||
|
|
||||||
|
const enrollments = await prisma.bidderEventEnrollment.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
include: {
|
||||||
|
bidder: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { bidder: { lastName: "asc" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, (_req, res) => {
|
const invoices = await prisma.invoice.findMany({
|
||||||
res.status(501).json({ error: "Not implemented" });
|
where: { eventId },
|
||||||
|
select: { bidderId: true, totalAmount: true, paidAmount: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceByBidder = Object.fromEntries(
|
||||||
|
invoices.map((inv) => [inv.bidderId, inv]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wonItemsByBidder = await prisma.auctionItem.groupBy({
|
||||||
|
by: ["currentHighBidderId"],
|
||||||
|
where: {
|
||||||
|
state: { in: ["sold", "closed"] },
|
||||||
|
auction: { eventId },
|
||||||
|
currentHighBidderId: { not: null },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
_sum: { currentHighBid: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wonMap = Object.fromEntries(
|
||||||
|
wonItemsByBidder.map((row) => [
|
||||||
|
row.currentHighBidderId,
|
||||||
|
{ count: row._count.id, total: Number(row._sum.currentHighBid ?? 0) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enrollments.map((e) => ({
|
||||||
|
...e,
|
||||||
|
invoice: invoiceByBidder[e.bidderId] ?? null,
|
||||||
|
won: wonMap[e.bidderId] ?? { count: 0, total: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Audit log ──────────────────────────────────────────────────────────────────
|
||||||
|
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, async (req, res) => {
|
||||||
|
const eventId = req.params["id"]!;
|
||||||
|
const page = parseInt(String(req.query["page"] ?? "1"), 10);
|
||||||
|
const limit = Math.min(parseInt(String(req.query["limit"] ?? "100"), 10), 500);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
staffUser: { select: { name: true, email: true, role: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.auditLog.count({ where: { eventId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ logs, total, page, pages: Math.ceil(total / limit) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,140 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/webhooks/stripe – Stripe webhook handler (raw body required)
|
* POST /api/webhooks/stripe
|
||||||
|
*
|
||||||
|
* Handles payment_intent.succeeded and payment_intent.payment_failed.
|
||||||
|
* Raw body is required for signature verification (mounted before express.json()).
|
||||||
*/
|
*/
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
|
||||||
export const webhooksRouter = Router();
|
export const webhooksRouter = Router();
|
||||||
|
|
||||||
// Raw body needed for Stripe signature verification
|
|
||||||
webhooksRouter.post(
|
webhooksRouter.post(
|
||||||
"/stripe",
|
"/stripe",
|
||||||
express.raw({ type: "application/json" }),
|
express.raw({ type: "application/json" }),
|
||||||
(_req, res) => {
|
async (req, res) => {
|
||||||
res.status(501).json({ error: "Not implemented" });
|
const sig = req.headers["stripe-signature"];
|
||||||
|
const secret = process.env["STRIPE_WEBHOOK_SECRET"];
|
||||||
|
|
||||||
|
if (!sig || !secret) {
|
||||||
|
res.status(400).json({ error: "Missing signature or webhook secret" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
const stripe = new Stripe(process.env["STRIPE_SECRET_KEY"] ?? "", {
|
||||||
|
apiVersion: "2024-04-10",
|
||||||
|
});
|
||||||
|
event = stripe.webhooks.constructEvent(req.body as Buffer, sig, secret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[webhook] signature verification failed", err);
|
||||||
|
res.status(400).json({ error: "Invalid signature" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "payment_intent.succeeded":
|
||||||
|
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "payment_intent.payment_failed":
|
||||||
|
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Ignore other event types
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[webhook] error handling ${event.type}`, err);
|
||||||
|
// Return 200 so Stripe doesn't retry — log and investigate separately
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Handlers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handlePaymentSucceeded(intent: Stripe.PaymentIntent): Promise<void> {
|
||||||
|
const { invoiceId, bidderId, eventId, campaignId } = intent.metadata;
|
||||||
|
|
||||||
|
// ── Invoice payment ─────────────────────────────────────────────────────────
|
||||||
|
if (invoiceId) {
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: { stripePaymentIntentId: intent.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { status: "succeeded" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
|
if (invoice) {
|
||||||
|
const newPaid = Number(invoice.paidAmount) + Number(intent.amount) / 100;
|
||||||
|
const total = Number(invoice.totalAmount);
|
||||||
|
const status = newPaid >= total ? "paid" : "partially_paid";
|
||||||
|
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { paidAmount: newPaid, status },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
action: "invoice_paid",
|
||||||
|
entityType: "Invoice",
|
||||||
|
entityId: invoiceId,
|
||||||
|
payload: { amount: intent.amount / 100, status },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[webhook] invoice ${invoiceId} → ${status} ($${newPaid})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Donation / paddle raise ─────────────────────────────────────────────────
|
||||||
|
if (!invoiceId && bidderId) {
|
||||||
|
const donation = await prisma.donation.findFirst({
|
||||||
|
where: { stripePaymentIntentId: intent.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (donation && campaignId) {
|
||||||
|
await prisma.paddleRaiseCampaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: { totalRaised: { increment: Number(intent.amount) / 100 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaign = await prisma.paddleRaiseCampaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (campaign) {
|
||||||
|
console.log(
|
||||||
|
`[webhook] paddle raise ${campaignId} total → $${Number(campaign.totalRaised)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaymentFailed(intent: Stripe.PaymentIntent): Promise<void> {
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: { stripePaymentIntentId: intent.id },
|
||||||
|
});
|
||||||
|
if (payment) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { status: "failed" },
|
||||||
|
});
|
||||||
|
console.warn(`[webhook] payment failed for intent ${intent.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Silent auction window scheduler.
|
||||||
|
*
|
||||||
|
* Polls every 10 seconds for windows whose closesAt has passed, closes them,
|
||||||
|
* marks each item as "closed", and broadcasts silent_item_closed to the event room.
|
||||||
|
*
|
||||||
|
* This runs entirely on the local server — no external dependencies — so it
|
||||||
|
* continues working when the internet is unavailable.
|
||||||
|
*/
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import type { Server } from "socket.io";
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
InterServerEvents,
|
||||||
|
SocketData,
|
||||||
|
} from "@storybid/shared";
|
||||||
|
|
||||||
|
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
export function startScheduler(io: IO): void {
|
||||||
|
console.log("[scheduler] starting silent auction window poller");
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
void closeExpiredWindows(io);
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeExpiredWindows(io: IO): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Find open windows whose close time has passed
|
||||||
|
const expiredWindows = await prisma.silentAuctionWindow.findMany({
|
||||||
|
where: {
|
||||||
|
status: "open",
|
||||||
|
closesAt: { lte: now },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: { state: { notIn: ["sold", "passed", "closed"] } },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const window of expiredWindows) {
|
||||||
|
// Mark the window closed
|
||||||
|
await prisma.silentAuctionWindow.update({
|
||||||
|
where: { id: window.id },
|
||||||
|
data: { status: "closed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close each item still in the window
|
||||||
|
for (const item of window.items) {
|
||||||
|
const closed = await prisma.auctionItem.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { state: "closed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write audit log entry
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
eventId: item.auction.eventId,
|
||||||
|
action: "item_auto_closed",
|
||||||
|
entityType: "AuctionItem",
|
||||||
|
entityId: item.id,
|
||||||
|
payload: {
|
||||||
|
windowId: window.id,
|
||||||
|
winnerId: closed.currentHighBidderId,
|
||||||
|
finalAmount: closed.currentHighBid
|
||||||
|
? Number(closed.currentHighBid)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to event room
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("silent_item_closed", {
|
||||||
|
itemId: item.id,
|
||||||
|
winnerId: closed.currentHighBidderId,
|
||||||
|
finalAmount: closed.currentHighBid ? Number(closed.currentHighBid) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[scheduler] closed item ${item.id} (lot ${item.lotNumber}) ` +
|
||||||
|
`winner=${closed.currentHighBidderId ?? "none"} ` +
|
||||||
|
`amount=${closed.currentHighBid ?? 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also open any windows whose opensAt has arrived
|
||||||
|
const pendingWindows = await prisma.silentAuctionWindow.findMany({
|
||||||
|
where: {
|
||||||
|
status: "pending",
|
||||||
|
opensAt: { lte: now },
|
||||||
|
closesAt: { gt: now },
|
||||||
|
},
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const window of pendingWindows) {
|
||||||
|
await prisma.silentAuctionWindow.update({
|
||||||
|
where: { id: window.id },
|
||||||
|
data: { status: "open" },
|
||||||
|
});
|
||||||
|
|
||||||
|
io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
|
||||||
|
windowId: window.id,
|
||||||
|
closesAt: window.closesAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[scheduler] opened window ${window.id} (${window.name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,13 +128,36 @@ export function registerLiveAuctionHandlers(io: IO, socket: Sock): void {
|
|||||||
socket.on("auctioneer_accept_bid", async (payload) => {
|
socket.on("auctioneer_accept_bid", async (payload) => {
|
||||||
if (!isStaff(socket.data.role)) return;
|
if (!isStaff(socket.data.role)) return;
|
||||||
|
|
||||||
|
// Resolve paddle:<number> → real bidderId via event enrollment
|
||||||
|
let bidderId = payload.bidderId;
|
||||||
|
if (bidderId.startsWith("paddle:")) {
|
||||||
|
const paddleNumber = bidderId.slice(7);
|
||||||
|
const item = await prisma.auctionItem.findUnique({
|
||||||
|
where: { id: payload.itemId },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
if (item) {
|
||||||
|
const enrollment = await prisma.bidderEventEnrollment.findFirst({
|
||||||
|
where: {
|
||||||
|
eventId: item.auction.eventId,
|
||||||
|
paddleNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!enrollment) {
|
||||||
|
console.warn(`[live] spotter: paddle ${paddleNumber} not found in event`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bidderId = enrollment.bidderId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await placeBid({
|
const result = await placeBid({
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
bidderId: payload.bidderId,
|
bidderId,
|
||||||
amount: payload.amount,
|
amount: payload.amount,
|
||||||
originMode: "public",
|
originMode: "public",
|
||||||
deviceId: socket.id, // spotter device = socket id
|
deviceId: socket.id,
|
||||||
clientSeq: Date.now(), // floor bids use server timestamp as seq
|
clientSeq: Date.now(),
|
||||||
clientCreatedAt: new Date(),
|
clientCreatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user