Phase 2 and Demo

This commit is contained in:
2026-05-02 20:14:15 -05:00
parent d909cb7c30
commit 056bd27f89
36 changed files with 3867 additions and 299 deletions
+4 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+36 -32
View File
@@ -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,39 +34,40 @@ import FundANeedPage from "./pages/admin/FundANeedPage.js";
export default function App() { export default function App() {
return ( return (
<> <Routes>
<ConnectivityBanner /> {/* ── Auth (no layout) ── */}
<Routes> <Route path="/login" element={<LoginPage />} />
{/* Auth */} <Route path="/verify" element={<VerifyPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/verify" element={<VerifyPage />} />
{/* Bidder */} {/* ── Staff tools (full-screen, no layout chrome) ── */}
<Route path="/" element={<HomePage />} /> <Route path="/staff/auctioneer" element={<AuctioneerPage />} />
<Route path="/live" element={<LivePage />} /> <Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/silent" element={<SilentPage />} /> <Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* ── Bidder shell ── */}
<Route element={<BidderLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/live" element={<LivePage />} />
<Route path="/silent" element={<SilentPage />} />
<Route path="/items/:id" element={<ItemPage />} /> <Route path="/items/:id" element={<ItemPage />} />
<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 />} />
<Route path="checkout" element={<AdminCheckoutPage />} />
<Route path="reporting" element={<AdminReportingPage />} />
<Route path="fund-a-need" element={<FundANeedPage />} />
</Route>
{/* Admin */} <Route path="*" element={<Navigate to="/" replace />} />
<Route path="/admin" element={<AdminDashboard />} /> </Routes>
<Route path="/admin/events" element={<AdminEventsPage />} />
<Route path="/admin/items" element={<AdminItemsPage />} />
<Route path="/admin/bidders" element={<AdminBiddersPage />} />
<Route path="/admin/checkout" element={<AdminCheckoutPage />} />
<Route path="/admin/reporting" element={<AdminReportingPage />} />
<Route path="/admin/fund-a-need" element={<FundANeedPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
); );
} }
@@ -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,
};
}
+95 -2
View File
@@ -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 &amp; live totals</p>
</div> </div>
</main> <div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Paddle raise setup &amp; live totals not yet implemented
</div>
</div>
); );
} }
+14 -6
View File
@@ -1,14 +1,22 @@
/** /**
* Admin → Items manage lots, categories, media, donor info, increments. * Admin → Items manage lots, categories, media, donor info, increments.
* TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart). * 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>
</main> <div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Item list &amp; editor not yet implemented
</div>
</div>
); );
} }
@@ -1,14 +1,17 @@
/** /**
* Admin → Reporting revenue, sell-through, bidder activity, audit log. * Admin → Reporting revenue, sell-through, bidder activity, audit log.
* TODO: fetch /api/reporting/events/:id/*. * 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>
); );
} }
+221 -11
View File
@@ -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> <p className="text-gold-300 font-black text-2xl tracking-tight leading-none">
{/* TODO: LoginForm component */} STORYBOOK FARM
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm"> </p>
LoginForm not yet implemented <p className="text-brand-200 text-sm mt-1.5">Sign in to bid</p>
</div>
{/* ── Tabs ── */}
<div className="flex border-b border-gray-100">
{(["email", "phone"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
tab === t
? "text-brand-700 border-b-2 border-brand-700"
: "text-gray-400 hover:text-gray-600"
}`}
>
{t === "email" ? "✉️ Email" : "📱 Phone"}
</button>
))}
</div>
<div className="p-6">
{/* ── Email tab ── */}
{tab === "email" && (
emailSent ? (
<div className="text-center space-y-3 py-2">
<div className="text-4xl">📬</div>
<p className="font-bold text-gray-900">Check your inbox</p>
<p className="text-sm text-gray-500">
We sent a sign-in link to <strong>{email}</strong>. The link expires in 15 minutes.
</p>
<button
onClick={() => { setEmailSent(false); setEmail(""); }}
className="text-brand-700 text-sm font-semibold hover:underline"
>
Use a different email
</button>
</div>
) : (
<form onSubmit={(e) => void handleEmailSubmit(e)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Email address
</label>
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="field"
/>
</div>
{emailError && <p className="text-red-500 text-sm">{emailError}</p>}
<button type="submit" disabled={emailLoading} className="btn-primary w-full">
{emailLoading ? "Sending…" : "Send sign-in link"}
</button>
</form>
)
)}
{/* ── Phone tab ── */}
{tab === "phone" && (
phonePhase === "entry" ? (
<form onSubmit={(e) => void handlePhoneSend(e)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Phone number
</label>
<input
type="tel"
required
autoFocus
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 000-0000"
className="field"
/>
<p className="text-xs text-gray-400 mt-1.5">
US numbers: enter 10 digits. International: include country code (+XX).
</p>
</div>
{phoneError && <p className="text-red-500 text-sm">{phoneError}</p>}
<button type="submit" disabled={phoneLoading} className="btn-primary w-full">
{phoneLoading ? "Sending…" : "Send code"}
</button>
</form>
) : (
<form onSubmit={(e) => void handleOtpVerify(e)} className="space-y-4">
<p className="text-sm text-gray-500 text-center">
We sent a 6-digit code to <strong>{phone}</strong>
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Verification code
</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
required
autoFocus
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="field text-center text-2xl font-bold tracking-[0.4em]"
/>
</div>
{phoneError && <p className="text-red-500 text-sm">{phoneError}</p>}
<button
type="submit"
disabled={phoneLoading || otp.length < 4}
className="btn-primary w-full"
>
{phoneLoading ? "Verifying…" : "Sign in"}
</button>
<button
type="button"
onClick={() => { setPhonePhase("entry"); setOtp(""); setPhoneError(""); }}
className="w-full text-sm text-gray-400 hover:text-gray-600 py-1"
>
Use a different number
</button>
</form>
)
)}
</div> </div>
</div> </div>
</main> </main>
+80 -4
View File
@@ -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>
); );
} }
+94 -21
View File
@@ -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>
{label} )}
</a> </div>
))}
</nav> {/* ── Cards grid (overlaps hero by 1rem) ── */}
</main> <div className="px-4 -mt-4 space-y-3">
{/* Event status card */}
<div className="card px-4 py-3 flex items-center gap-3 animate-fade-in">
<span className="text-2xl">🌿</span>
<div>
<p className="font-semibold text-sm text-gray-800">Storybook Farm Charity Gala</p>
<p className="text-xs text-gray-400 mt-0.5">Auction is live good luck!</p>
</div>
<span className="ml-auto w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
</div>
{/* Quick-action grid */}
<div className="grid grid-cols-2 gap-3 pb-4">
{QUICK_ACTIONS.map(({ to, label, sub, bg, text, sub_text, icon, border }) => (
<Link
key={to}
to={to}
className={`rounded-2xl p-4 flex flex-col gap-1 shadow-card active:scale-[0.97] transition-transform ${bg} ${border ? "border border-gray-100" : ""}`}
>
<span className="text-2xl leading-none">{icon}</span>
<p className={`font-bold text-sm mt-1 ${text}`}>{label}</p>
<p className={`text-xs ${sub_text}`}>{sub}</p>
</Link>
))}
</div>
</div>
</div>
); );
} }
@@ -7,12 +7,17 @@
* - Media carousel (images, video embed, documents) * - 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>
); );
} }
+51 -42
View File
@@ -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> </div>
</main>
{/* Sign out */}
<button
onClick={logout}
className="btn-ghost w-full text-red-500 border-red-200 hover:bg-red-50"
>
Sign out
</button>
</div>
); );
} }
+25 -29
View File
@@ -27,17 +27,18 @@ export default function SilentPage({ eventId, auctionId }: Props) {
if (!items.length) { 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>
</header>
<div className="flex flex-1 overflow-hidden">
{/* ── Item list (left) ─────────────────────────────────────────────── */}
<aside className="w-64 bg-gray-900 border-r border-gray-800 overflow-y-auto flex-shrink-0">
{loading ? (
<p className="text-gray-500 text-sm p-4">Loading lots</p>
) : (
<ul>
{items.map((item) => {
const isActive = currentItem?.id === item.id;
return (
<li key={item.id}>
<button
onClick={() => {
if (item.state === "preview") activateItem(item.id);
}}
className={`w-full text-left px-4 py-3 border-b border-gray-800 hover:bg-gray-800 transition-colors ${
isActive ? "bg-gray-800 border-l-2 border-l-brand-500" : ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400 font-mono">
{item.lotNumber}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded ${STATE_COLOR[item.state as ItemState] ?? ""}`}
>
{STATE_LABEL[item.state as ItemState] ?? item.state}
</span>
</div>
<p className="text-sm font-medium mt-1 leading-tight truncate">
{item.title}
</p>
{item.currentHighBid != null && (
<p className="text-xs text-brand-400 mt-0.5">
${item.currentHighBid.toLocaleString()}
</p>
)}
</button>
</li>
);
})}
</ul>
)}
</aside>
{/* ── Active lot controls (right) ──────────────────────────────────── */}
<main className="flex-1 p-6 overflow-y-auto space-y-6">
{!currentItem ? (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500 text-lg">
Select a lot from the list to activate it
</p>
</div>
) : (
<>
{/* Item info */}
<div className="bg-gray-900 rounded-xl p-5 space-y-1">
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
<h2 className="text-2xl font-bold">{currentItem.title}</h2>
{currentItem.donorName && (
<p className="text-gray-400 text-sm">Donated by {currentItem.donorName}</p>
)}
<p className="text-gray-500 text-xs">
Opening ${currentItem.openingBid.toLocaleString()} ·
Increment ${currentItem.bidIncrement.toLocaleString()}
{currentItem.reservePrice != null &&
` · Reserve $${currentItem.reservePrice.toLocaleString()}`}
</p>
</div>
{/* Current bid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl p-5 text-center">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-1">
Current Bid
</p>
<p className="text-5xl font-black text-brand-400">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 text-center">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-1">
Called Amount
</p>
<p className="text-5xl font-black text-white">
{calledAmount != null ? `$${calledAmount.toLocaleString()}` : "—"}
</p>
</div>
</div>
{/* Call next bid */}
{!isClosed && (
<div className="bg-gray-900 rounded-xl p-5 space-y-3">
<p className="text-gray-400 text-sm font-medium">Call next bid</p>
<div className="flex gap-3">
<button
onClick={() => callNextBid(currentItem.id, suggestNextBid())}
className="flex-1 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 font-bold text-lg transition-colors"
>
${suggestNextBid().toLocaleString()}
</button>
<input
type="number"
placeholder="Custom $"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
className="w-32 bg-gray-800 border border-gray-700 rounded-lg px-3 text-white placeholder-gray-600 focus:outline-none focus:border-brand-500"
/>
<button
onClick={handleCallAmount}
disabled={!customAmount}
className="px-4 py-3 rounded-lg bg-gray-700 hover:bg-gray-600 font-medium disabled:opacity-40 transition-colors"
>
Call
</button>
</div>
</div>
)}
{/* State controls */}
{!isClosed && (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => goingOnce(currentItem.id)}
disabled={state !== "active"}
className="py-4 rounded-xl bg-yellow-600 hover:bg-yellow-500 font-bold text-lg disabled:opacity-30 transition-colors"
>
Going Once
</button>
<button
onClick={() => goingTwice(currentItem.id)}
disabled={state !== "going_once"}
className="py-4 rounded-xl bg-orange-600 hover:bg-orange-500 font-bold text-lg disabled:opacity-30 transition-colors"
>
Going Twice
</button>
<button
onClick={() => sold(currentItem.id)}
disabled={state !== "going_twice" && state !== "going_once" && state !== "active"}
className="py-4 rounded-xl bg-green-600 hover:bg-green-500 font-bold text-xl disabled:opacity-30 transition-colors"
>
SOLD
</button>
<button
onClick={() => pass(currentItem.id)}
disabled={isClosed}
className="py-4 rounded-xl bg-gray-700 hover:bg-gray-600 font-bold text-lg disabled:opacity-30 transition-colors"
>
Pass
</button>
</div>
)}
{/* Recent bids */}
{recentBids.length > 0 && (
<div className="bg-gray-900 rounded-xl p-5">
<p className="text-gray-400 text-xs uppercase tracking-widest mb-3">
Bid stream
</p>
<ul className="space-y-2">
{recentBids.map((b, i) => (
<li
key={b.id}
className={`flex justify-between text-sm ${
i === 0 ? "text-white font-bold" : "text-gray-400"
}`}
>
<span className="font-mono text-xs text-gray-500">
{new Date(b.createdAt).toLocaleTimeString()}
</span>
<span>${Number(b.amount).toLocaleString()}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
</main>
</div> </div>
</main> </div>
); );
} }
@@ -1,5 +1,5 @@
/** /**
* Check-in station search bidders, scan QR, assign paddle, confirm payment readiness. * Check-in station search bidders, scan QR, assign paddle, confirm payment readiness.
* *
* TODO: * 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 &amp; 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 */}
<div className="text-center space-y-2"> {stateBanner && (
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p> <div
<p className="text-6xl font-bold"></p> className={`${stateBg} text-center py-4 text-3xl font-black tracking-widest uppercase animate-pulse`}
>
{stateBanner}
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col items-center justify-center gap-8 px-12 py-8">
{/* Org / event branding */}
<p className="text-brand-300 text-sm uppercase tracking-[0.3em] font-semibold">
Live Auction
</p>
{item ? (
<>
{/* Lot + title */}
<div className="text-center space-y-2">
<p className="text-brand-400 text-xl font-mono">Lot {item.lotNumber}</p>
<h1 className="text-5xl font-black leading-tight text-center max-w-3xl">
{item.title}
</h1>
{item.donorName && (
<p className="text-brand-300 text-lg">Donated by {item.donorName}</p>
)}
</div>
{/* Bid display */}
<div className="flex gap-16 items-end">
<div className="text-center">
<p className="text-brand-300 text-sm uppercase tracking-widest mb-1">
Current Bid
</p>
<p className="text-8xl font-black tabular-nums">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
{calledAmount != null && calledAmount !== currentBid && (
<div className="text-center opacity-70">
<p className="text-brand-300 text-sm uppercase tracking-widest mb-1">
Next Bid
</p>
<p className="text-5xl font-bold tabular-nums">
${calledAmount.toLocaleString()}
</p>
</div>
)}
</div>
{/* Fair market value */}
{item.fairMarketValue != null && (
<p className="text-brand-400 text-sm">
Fair market value: ${item.fairMarketValue.toLocaleString()}
</p>
)}
</>
) : (
<div className="text-center space-y-4">
<h1 className="text-6xl font-black tracking-tight">Storybid</h1>
<p className="text-brand-300 text-xl">Auction beginning soon</p>
</div>
)}
</div> </div>
<div className="text-center">
<p className="text-xl text-brand-200">Current Bid</p> {/* Fund-a-Need thermometer */}
<p className="text-8xl font-black">$</p> {campaign && (
<p className="text-2xl text-brand-300 mt-2">Paddle </p> <div className="bg-brand-800 px-12 py-6 space-y-2">
</div> <div className="flex justify-between items-baseline">
</main> <span className="text-brand-200 font-bold">{campaign.name}</span>
<span className="text-white text-xl font-black">
${campaign.totalRaised.toLocaleString()}
{campaign.goal && (
<span className="text-brand-300 text-base font-normal">
{" "}/ ${campaign.goal.toLocaleString()}
</span>
)}
</span>
</div>
{campaign.goal && (
<div className="w-full bg-brand-700 rounded-full h-4 overflow-hidden">
<div
className="h-4 bg-green-400 rounded-full transition-all duration-700"
style={{
width: `${Math.min(
100,
Math.round((campaign.totalRaised / campaign.goal) * 100),
)}%`,
}}
/>
</div>
)}
</div>
)}
</div>
); );
} }
+160 -9
View File
@@ -1,19 +1,170 @@
/** /**
* Spotter mode floor volunteer enters bids by paddle number. * 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>
); );
} }
+12
View File
@@ -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";
}
+68 -8
View File
@@ -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", fontFamily: {
600: "#2563eb", sans: [
700: "#1d4ed8", "Inter",
900: "#1e3a8a", "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",
}, },
}, },
}, },
+2
View File
@@ -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}`);
+240 -15
View File
@@ -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 },
});
if (!invoice) {
// 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 },
});
const totalAmount = wonItems.reduce(
(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 },
});
res.json({ invoice, wonItems });
}); });
checkoutRouter.post("/:bidderId/pay", 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." });
}
}); });
checkoutRouter.post("/:bidderId/capture", requireAuth, requireRole("admin", "event_manager"), (_req, res) => { // ── Donation ───────────────────────────────────────────────────────────────────
res.status(501).json({ error: "Not implemented" });
const DonateSchema = z.object({
eventId: z.string(),
amount: z.number().positive(),
campaignId: z.string().optional(),
anonymous: z.boolean().default(false),
}); });
checkoutRouter.post("/donate", requireAuth, (_req, res) => { checkoutRouter.post("/donate", requireAuth, async (req, res) => {
res.status(501).json({ error: "Not implemented" }); 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." });
}
}); });
checkoutRouter.post("/paddle-raise", requireAuth, (_req, res) => { // ── Paddle Raise ───────────────────────────────────────────────────────────────
res.status(501).json({ error: "Not implemented" }); // 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." });
}
}); });
+140 -9
View File
@@ -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" } },
});
const invoices = await prisma.invoice.findMany({
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);
}); });
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, (_req, res) => { // ── Audit log ──────────────────────────────────────────────────────────────────
res.status(501).json({ error: "Not implemented" }); 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) });
}); });
+128 -4
View File
@@ -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}`);
}
}
+117
View File
@@ -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})`);
}
}
+26 -3
View File
@@ -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(),
}); });