Scaffold and Phase 1

This commit is contained in:
2026-05-02 19:46:42 -05:00
parent ab74e7cad4
commit d909cb7c30
92 changed files with 4967 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ConnectivityBanner } from "./components/ConnectivityBanner.js";
// Bidder-facing pages
import HomePage from "./pages/bidder/HomePage.js";
import LivePage from "./pages/bidder/LivePage.js";
import SilentPage from "./pages/bidder/SilentPage.js";
import ItemPage from "./pages/bidder/ItemPage.js";
import MyBidsPage from "./pages/bidder/MyBidsPage.js";
import CheckoutPage from "./pages/bidder/CheckoutPage.js";
import ProfilePage from "./pages/bidder/ProfilePage.js";
// Auth pages
import LoginPage from "./pages/auth/LoginPage.js";
import VerifyPage from "./pages/auth/VerifyPage.js";
// Staff pages
import AuctioneerPage from "./pages/staff/AuctioneerPage.js";
import SpotterPage from "./pages/staff/SpotterPage.js";
import CheckInPage from "./pages/staff/CheckInPage.js";
import DisplayBoardPage from "./pages/staff/DisplayBoardPage.js";
// Admin pages
import AdminDashboard from "./pages/admin/DashboardPage.js";
import AdminEventsPage from "./pages/admin/EventsPage.js";
import AdminItemsPage from "./pages/admin/ItemsPage.js";
import AdminBiddersPage from "./pages/admin/BiddersPage.js";
import AdminCheckoutPage from "./pages/admin/CheckoutPage.js";
import AdminReportingPage from "./pages/admin/ReportingPage.js";
import FundANeedPage from "./pages/admin/FundANeedPage.js";
export default function App() {
return (
<>
<ConnectivityBanner />
<Routes>
{/* Auth */}
<Route path="/login" element={<LoginPage />} />
<Route path="/verify" element={<VerifyPage />} />
{/* Bidder */}
<Route path="/" element={<HomePage />} />
<Route path="/live" element={<LivePage />} />
<Route path="/silent" element={<SilentPage />} />
<Route path="/items/:id" element={<ItemPage />} />
<Route path="/my-bids" element={<MyBidsPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Staff optimized single-task views */}
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
<Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* Admin */}
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/events" element={<AdminEventsPage />} />
<Route path="/admin/items" element={<AdminItemsPage />} />
<Route path="/admin/bidders" element={<AdminBiddersPage />} />
<Route path="/admin/checkout" element={<AdminCheckoutPage />} />
<Route path="/admin/reporting" element={<AdminReportingPage />} />
<Route path="/admin/fund-a-need" element={<FundANeedPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}
@@ -0,0 +1,21 @@
import { useConnectivityStore } from "../store/connectivity.js";
const labels: Record<string, { text: string; className: string }> = {
connected: { text: "Connected", className: "bg-green-500" },
local: { text: "Local network offline-capable", className: "bg-yellow-500" },
offline: { text: "Offline bids will sync when reconnected", className: "bg-red-500" },
};
export function ConnectivityBanner() {
const status = useConnectivityStore((s) => s.status);
if (status === "connected") return null;
const { text, className } = labels[status]!;
return (
<div className={`${className} text-white text-center text-sm py-1 px-4 font-medium`}>
{text}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* Real-time state hook for the live auction bidder view.
* Subscribes to item_activated, next_live_bid, live_bid_accepted,
* item_state_changed, item_sold.
*/
import { useState, useEffect } from "react";
import { getSocket } from "../lib/socket.js";
import type { AuctionItem, Bid, ItemState } from "@storybid/shared";
export interface LiveAuctionState {
currentItem: AuctionItem | null;
currentBid: number | null;
calledAmount: number | null;
state: ItemState | null;
recentBids: Bid[];
}
export function useLiveAuction(eventId: string) {
const [state, setState] = useState<LiveAuctionState>({
currentItem: null,
currentBid: null,
calledAmount: null,
state: null,
recentBids: [],
});
useEffect(() => {
const socket = getSocket();
socket.emit("join_event", eventId);
socket.on("item_activated", ({ item }) => {
setState({
currentItem: item,
currentBid: item.currentHighBid,
calledAmount: item.openingBid,
state: item.state,
recentBids: [],
});
});
socket.on("next_live_bid", ({ amount }) => {
setState((prev) => ({ ...prev, calledAmount: amount }));
});
socket.on("live_bid_accepted", ({ bid, item }) => {
setState((prev) => ({
...prev,
currentBid: item.currentHighBid,
state: item.state,
currentItem: item,
recentBids: [bid, ...prev.recentBids].slice(0, 10),
}));
});
socket.on("item_state_changed", ({ itemId, state: newState }) => {
setState((prev) => {
if (prev.currentItem?.id !== itemId) return prev;
return { ...prev, state: newState };
});
});
socket.on("item_sold", ({ itemId, amount }) => {
setState((prev) => {
if (prev.currentItem?.id !== itemId) return prev;
return { ...prev, currentBid: amount, state: "sold" };
});
});
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 placeBid = (itemId: string, amount: number, deviceId: string, clientSeq: number) => {
const socket = getSocket();
socket.emit("place_live_bid", {
itemId,
amount,
deviceId,
clientSeq,
clientCreatedAt: new Date().toISOString(),
});
};
return { ...state, placeBid };
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Manages the IndexedDB outbox queue.
*
* - Adds bids to the outbox when offline or when the server rejects the socket call.
* - Watches navigator.onLine + socket reconnect events to trigger sync.
* - Emits `sync_outbox` via Socket.io and removes successfully synced entries.
*/
import { useEffect, useCallback } from "react";
import { v4 as uuidv4 } from "uuid";
import { db } from "../lib/db.js";
import { getSocket } from "../lib/socket.js";
import { useConnectivityStore } from "../store/connectivity.js";
import type { OutboxBid } from "@storybid/shared";
const DEVICE_ID_KEY = "sb_device_id";
function getDeviceId(): string {
let id = localStorage.getItem(DEVICE_ID_KEY);
if (!id) {
id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
}
return id;
}
let clientSeq = 0;
export function useOfflineBids() {
const setStatus = useConnectivityStore((s) => s.setStatus);
const syncOutbox = useCallback(async () => {
const pending = await db.outbox.toArray();
if (!pending.length) return;
const socket = getSocket();
if (!socket.connected) return;
socket.emit(
"sync_outbox",
pending.map((b) => ({
localId: b.localId,
itemId: b.itemId,
amount: b.amount,
deviceId: b.deviceId,
clientSeq: b.clientSeq,
clientCreatedAt: b.clientCreatedAt,
})),
);
}, []);
useEffect(() => {
const socket = getSocket();
// Listen for sync results and clear acknowledged entries
const onSyncResult = (result: { localId: string; accepted: boolean }) => {
if (result.accepted) {
void db.outbox.delete(result.localId);
}
};
const onReconnect = () => {
setStatus("connected");
void syncOutbox();
};
const onDisconnect = () => {
setStatus(navigator.onLine ? "local" : "offline");
};
const onOnline = () => {
setStatus("connected");
void syncOutbox();
};
const onOffline = () => setStatus("offline");
socket.on("bid_sync_result", onSyncResult);
socket.on("connect", onReconnect);
socket.on("disconnect", onDisconnect);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
socket.off("bid_sync_result", onSyncResult);
socket.off("connect", onReconnect);
socket.off("disconnect", onDisconnect);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [setStatus, syncOutbox]);
/**
* Queue a bid in IndexedDB. Call this when the socket is disconnected
* or when you want to guarantee delivery before the network confirms.
*/
const queueBid = useCallback(
async (itemId: string, bidderId: string, amount: number): Promise<string> => {
const entry: OutboxBid = {
localId: uuidv4(),
itemId,
bidderId,
amount,
clientCreatedAt: new Date().toISOString(),
deviceId: getDeviceId(),
clientSeq: ++clientSeq,
attempts: 0,
lastAttemptAt: null,
};
await db.outbox.add(entry);
return entry.localId;
},
[],
);
return { queueBid, syncOutbox, getDeviceId };
}
@@ -0,0 +1,80 @@
/**
* Real-time state hook for the silent auction catalog.
* Subscribes to silent_bid_accepted, silent_outbid, silent_window_closing,
* silent_window_extended, silent_item_closed.
*/
import { useState, useEffect, useCallback } from "react";
import { getSocket } from "../lib/socket.js";
import { useOfflineBids } from "./useOfflineBids.js";
import { useAuthStore } from "../store/auth.js";
import type { AuctionItem } from "@storybid/shared";
export function useSilentAuction(eventId: string) {
const [items, setItems] = useState<AuctionItem[]>([]);
const [outbidItemIds, setOutbidItemIds] = useState<Set<string>>(new Set());
const bidderId = useAuthStore((s) => s.bidder?.id);
const { queueBid, getDeviceId } = useOfflineBids();
let clientSeq = 0;
useEffect(() => {
const socket = getSocket();
socket.emit("join_event", eventId);
socket.on("silent_bid_accepted", ({ item }) => {
setItems((prev) =>
prev.map((i) => (i.id === item.id ? item : i)),
);
// Clear outbid flag if we just won
if (item.currentHighBidderId === bidderId) {
setOutbidItemIds((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
}
});
socket.on("silent_outbid", ({ itemId }) => {
setOutbidItemIds((prev) => new Set([...prev, itemId]));
});
socket.on("silent_item_closed", ({ itemId }) => {
setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, state: "closed" } : i)),
);
});
return () => {
socket.emit("leave_event", eventId);
socket.off("silent_bid_accepted");
socket.off("silent_outbid");
socket.off("silent_item_closed");
};
}, [eventId, bidderId]);
const placeSilentBid = useCallback(
async (itemId: string, amount: number) => {
if (!bidderId) return;
const socket = getSocket();
const deviceId = getDeviceId();
const seq = ++clientSeq;
if (socket.connected) {
socket.emit("place_silent_bid", {
itemId,
amount,
deviceId,
clientSeq: seq,
clientCreatedAt: new Date().toISOString(),
});
} else {
// Offline write to IndexedDB outbox
await queueBid(itemId, bidderId, amount);
}
},
[bidderId, getDeviceId, queueBid],
);
return { items, setItems, outbidItemIds, placeSilentBid };
}
+14
View File
@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
/* Prevent text-size inflation on mobile */
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-white text-gray-900 antialiased;
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Thin fetch wrapper attaches the auth token, handles JSON, and throws
* typed errors. All API modules import from here.
*/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
function getToken(): string | null {
return localStorage.getItem("sb_token");
}
export async function apiFetch<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const token = getToken();
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(path, { ...init, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new ApiError(res.status, body.error ?? res.statusText);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
};
+20
View File
@@ -0,0 +1,20 @@
/**
* IndexedDB via Dexie persists the offline bid outbox and cached event data.
*/
import Dexie, { type Table } from "dexie";
import type { OutboxBid } from "@storybid/shared";
export class StorybidDB extends Dexie {
outbox!: Table<OutboxBid, string>;
constructor() {
super("storybid");
this.version(1).stores({
// localId is the primary key; index itemId for item-scoped queries
outbox: "localId, itemId, bidderId, clientCreatedAt",
});
}
}
export const db = new StorybidDB();
+44
View File
@@ -0,0 +1,44 @@
import { io, type Socket } from "socket.io-client";
import type {
ServerToClientEvents,
ClientToServerEvents,
} from "@storybid/shared";
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
let socket: AppSocket | null = null;
/**
* Returns (or lazily creates) the singleton Socket.io client.
*
* The connection manager tries the public URL first, then the local-LAN
* hostname injected at build-time or from org settings. The server emits
* `sync_status_changed` once the transport is established so the UI can
* show which path is in use.
*/
export function getSocket(token?: string): AppSocket {
if (socket) return socket;
socket = io({
auth: token ? { token } : undefined,
// Reconnect aggressively events are high-stakes
reconnectionAttempts: Infinity,
reconnectionDelay: 500,
reconnectionDelayMax: 5000,
});
socket.on("connect", () => {
console.log("[socket] connected via", socket?.io.engine.transport.name);
});
socket.on("disconnect", (reason) => {
console.warn("[socket] disconnected:", reason);
});
return socket;
}
export function disconnectSocket(): void {
socket?.disconnect();
socket = null;
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
@@ -0,0 +1,22 @@
/**
* Admin → Bidders profiles, paddles, QR codes, CSV import.
* TODO: CRUD + bulk import via /api/bidders.
*/
export default function AdminBiddersPage() {
return (
<main className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Bidder Manager</h1>
<div className="flex gap-2">
<button className="px-3 py-2 border rounded-lg text-sm">Import CSV</button>
<button className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
+ Add Bidder
</button>
</div>
</div>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Bidder list not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Admin → Checkout cashier station; find bidder, take payment, print receipt.
* TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture.
*/
export default function AdminCheckoutPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Checkout</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Cashier station not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,19 @@
/**
* Admin dashboard overview of events, recent bids, revenue snapshot.
* TODO: fetch org summary from /api/reporting.
*/
export default function AdminDashboard() {
return (
<main className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{["Events", "Bidders", "Revenue"].map((label) => (
<div key={label} className="border rounded-xl p-5 text-center">
<p className="text-gray-500 text-sm">{label}</p>
<p className="text-3xl font-bold mt-1"></p>
</div>
))}
</div>
</main>
);
}
@@ -0,0 +1,19 @@
/**
* Admin → Events list, create, edit events.
* TODO: CRUD via /api/events.
*/
export default function AdminEventsPage() {
return (
<main className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Events</h1>
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
+ New Event
</button>
</div>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Events list not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Admin → Fund-a-Need / Paddle Raise set tiers, open campaign, show live total.
* TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events.
*/
export default function FundANeedPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Fund-a-Need</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Paddle raise setup & live totals not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Admin → Items manage lots, categories, media, donor info, increments.
* TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart).
*/
export default function AdminItemsPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Item Manager</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Item list & editor not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Admin → Reporting revenue, sell-through, bidder activity, audit log.
* TODO: fetch /api/reporting/events/:id/*.
*/
export default function AdminReportingPage() {
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Reporting</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Reports not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,20 @@
/**
* Login email magic link or SMS OTP entry point.
* TODO: implement magic-link request form and OTP flow.
*/
export default function LoginPage() {
return (
<main className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-bold text-center">Sign in to bid</h1>
<p className="text-center text-gray-500 text-sm">
Enter your email for a magic link, or your phone number for a one-time code.
</p>
{/* TODO: LoginForm component */}
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm">
LoginForm not yet implemented
</div>
</div>
</main>
);
}
@@ -0,0 +1,11 @@
/**
* Verify handles magic-link ?token= callback and OTP confirmation.
* TODO: read token from URL, call /api/auth/verify, redirect to /.
*/
export default function VerifyPage() {
return (
<main className="min-h-screen flex items-center justify-center p-4">
<p className="text-gray-500">Verifying</p>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Bidder checkout shows won lots, total, and Stripe Payment Element.
* TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success.
*/
export default function CheckoutPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Checkout</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Stripe checkout not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,27 @@
/**
* Bidder home event welcome screen, quick nav to Live / Silent / My Bids.
* TODO: fetch event details, show upcoming lots, paddle number, QR code.
*/
export default function HomePage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-2xl font-bold">Welcome to the Auction</h1>
<nav className="grid grid-cols-2 gap-3">
{[
{ label: "🎙 Live Auction", href: "/live" },
{ label: "🔇 Silent Auction", href: "/silent" },
{ label: "📋 My Bids", href: "/my-bids" },
{ label: "💳 Checkout", href: "/checkout" },
].map(({ label, href }) => (
<a
key={href}
href={href}
className="block rounded-xl border border-gray-200 p-5 text-center font-semibold text-brand-700 hover:bg-brand-50"
>
{label}
</a>
))}
</nav>
</main>
);
}
@@ -0,0 +1,18 @@
/**
* Individual silent auction item detail page.
* Shows media gallery, description, bid history, and bid form.
*
* TODO:
* - Load item by :id param
* - Media carousel (images, video embed, documents)
* - Place bid form with offline-outbox fallback via db.outbox
*/
export default function ItemPage() {
return (
<main className="p-4 space-y-4">
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Item detail not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,108 @@
/**
* Live auction bidder view.
*
* Shows the current lot, current bid, and a single "Bid $X" button for the
* auctioneer-called amount. Real-time updates via Socket.io.
* Falls back gracefully when no lot is active.
*/
import { useParams } from "react-router-dom";
import { useLiveAuction } from "../../hooks/useLiveAuction.js";
import { useOfflineBids } from "../../hooks/useOfflineBids.js";
const STATE_LABELS: Record<string, string> = {
preview: "Up next",
active: "Bidding open",
going_once: "Going once…",
going_twice: "Going twice…",
sold: "SOLD",
passed: "Passed",
};
export default function LivePage() {
// eventId comes from route or a global store; use param or fallback
const { eventId = "" } = useParams<{ eventId?: string }>();
const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } =
useLiveAuction(eventId);
const { getDeviceId } = useOfflineBids();
let clientSeq = 0;
const handleBid = () => {
if (!currentItem || calledAmount == null) return;
placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq);
};
const isSold = state === "sold" || state === "passed";
const canBid = state === "active" || state === "going_once" || state === "going_twice";
return (
<main className="min-h-screen flex flex-col p-4 gap-6">
{/* Status banner */}
<div className="text-center">
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold">
Live Auction
</p>
{state && (
<span
className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-bold ${
isSold ? "bg-gray-200 text-gray-500" : "bg-brand-100 text-brand-700"
}`}
>
{STATE_LABELS[state] ?? state}
</span>
)}
</div>
{currentItem ? (
<>
{/* Item info */}
<div className="text-center space-y-1">
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
<h1 className="text-2xl font-bold">{currentItem.title}</h1>
{currentItem.donorName && (
<p className="text-sm text-gray-500">Donated by {currentItem.donorName}</p>
)}
</div>
{/* Current bid */}
<div className="text-center">
<p className="text-sm text-gray-400 uppercase tracking-wide">Current bid</p>
<p className="text-5xl font-black text-brand-700">
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
</p>
</div>
{/* Called amount + bid button */}
{calledAmount != null && (
<button
onClick={handleBid}
disabled={!canBid}
className="w-full py-6 rounded-2xl bg-brand-600 text-white text-3xl font-black shadow-lg active:scale-95 transition-transform disabled:opacity-40 disabled:cursor-not-allowed"
>
Bid ${calledAmount.toLocaleString()}
</button>
)}
{/* Recent bids stream */}
{recentBids.length > 0 && (
<section>
<p className="text-xs uppercase tracking-widest text-gray-400 mb-2">Recent bids</p>
<ul className="space-y-1">
{recentBids.map((b) => (
<li key={b.id} className="flex justify-between text-sm">
<span className="text-gray-500">{b.createdAt}</span>
<span className="font-semibold">${Number(b.amount).toLocaleString()}</span>
</li>
))}
</ul>
</section>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-gray-400 text-lg">Waiting for the auctioneer to open a lot</p>
</div>
)}
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Bidder's personal bid history and watchlist.
* TODO: fetch /api/bidders/me/bids, show winning / outbid status per item.
*/
export default function MyBidsPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">My Bids</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Bid history not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,14 @@
/**
* Bidder profile paddle number, contact info, digital paddle QR, notifications prefs.
* TODO: fetch /api/bidders/me, render paddle QR code.
*/
export default function ProfilePage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Profile</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Profile & digital paddle not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,106 @@
/**
* Silent auction catalog.
* Loads items from the API, then keeps them live via Socket.io.
* Outbid items are highlighted; offline bids queue to IndexedDB.
*/
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { useSilentAuction } from "../../hooks/useSilentAuction.js";
import { api } from "../../lib/api.js";
import type { AuctionItem } from "@storybid/shared";
interface Props {
eventId: string;
auctionId: string;
}
export default function SilentPage({ eventId, auctionId }: Props) {
const { items, setItems, outbidItemIds, placeSilentBid } = useSilentAuction(eventId);
// Initial load from REST catalog
useEffect(() => {
api
.get<AuctionItem[]>(`/api/items?auctionId=${auctionId}`)
.then(setItems)
.catch(console.error);
}, [auctionId, setItems]);
if (!items.length) {
return (
<main className="p-4">
<h1 className="text-xl font-bold mb-4">Silent Auction</h1>
<p className="text-gray-400">Loading items</p>
</main>
);
}
return (
<main className="p-4 space-y-4">
<h1 className="text-xl font-bold">Silent Auction</h1>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item) => {
const isOutbid = outbidItemIds.has(item.id);
const isClosed = item.state === "closed" || item.state === "passed";
const minNext = item.currentHighBid != null
? item.currentHighBid + item.bidIncrement
: item.openingBid;
return (
<li
key={item.id}
className={`border rounded-xl overflow-hidden shadow-sm ${
isOutbid ? "border-red-400" : "border-gray-200"
}`}
>
{/* Outbid banner */}
{isOutbid && (
<div className="bg-red-50 text-red-600 text-xs font-bold px-3 py-1">
You've been outbid!
</div>
)}
<div className="p-4 space-y-2">
<div className="flex justify-between items-start">
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
isClosed
? "bg-gray-100 text-gray-400"
: "bg-green-100 text-green-700"
}`}
>
{isClosed ? "Closed" : "Open"}
</span>
</div>
<Link to={`/items/${item.id}`} className="block font-semibold hover:text-brand-600">
{item.title}
</Link>
<div className="flex justify-between items-end">
<div>
<p className="text-xs text-gray-400">Current bid</p>
<p className="text-lg font-bold text-brand-700">
{item.currentHighBid != null
? `$${item.currentHighBid.toLocaleString()}`
: `Starting at $${item.openingBid.toLocaleString()}`}
</p>
</div>
{!isClosed && (
<button
onClick={() => void placeSilentBid(item.id, minNext)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-bold hover:bg-brand-700 active:scale-95 transition-transform"
>
Bid ${minNext.toLocaleString()}
</button>
)}
</div>
</div>
</li>
);
})}
</ul>
</main>
);
}
@@ -0,0 +1,20 @@
/**
* Auctioneer console optimised for tablet in landscape.
* Shows: current lot, current bid, next callable bid, recent bid stream,
* and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass.
*
* TODO:
* - Subscribe to all live auction socket events
* - Emit auctioneer_* events on button press
* - Display large-format current bid and paddle number
*/
export default function AuctioneerPage() {
return (
<main className="min-h-screen bg-gray-900 text-white p-6 space-y-6">
<h1 className="text-2xl font-bold">Auctioneer Console</h1>
<div className="border border-dashed border-gray-600 rounded-xl p-8 text-center text-gray-500 text-sm">
Live auction controls not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,19 @@
/**
* Check-in station search bidders, scan QR, assign paddle, confirm payment readiness.
*
* TODO:
* - Search /api/bidders?eventId=&q=
* - QR scanner via device camera
* - POST /api/check-in/:id on confirm
* - Show payment-on-file indicator
*/
export default function CheckInPage() {
return (
<main className="p-4 space-y-4">
<h1 className="text-2xl font-bold">Check-In</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
QR scan & bidder search not yet implemented
</div>
</main>
);
}
@@ -0,0 +1,26 @@
/**
* Display board read-only fullscreen view for projector / TV.
* Shows: current item, current bid, bidder paddle, org branding,
* and optionally a fundraising thermometer.
*
* TODO:
* - Subscribe to live auction events (read-only socket connection)
* - Fullscreen CSS layout with large typography
* - Paddle raise thermometer via paddle_raise_update events
*/
export default function DisplayBoardPage() {
return (
<main className="min-h-screen bg-brand-900 text-white flex flex-col items-center justify-center p-8 space-y-8">
<h1 className="text-5xl font-black tracking-tight">Storybid</h1>
<div className="text-center space-y-2">
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p>
<p className="text-6xl font-bold"></p>
</div>
<div className="text-center">
<p className="text-xl text-brand-200">Current Bid</p>
<p className="text-8xl font-black">$</p>
<p className="text-2xl text-brand-300 mt-2">Paddle </p>
</div>
</main>
);
}
@@ -0,0 +1,19 @@
/**
* Spotter mode floor volunteer enters bids by paddle number.
* Simple: paddle number input + confirm button. Emits auctioneer_accept_bid.
*
* TODO:
* - Show current item and called amount (read-only)
* - Large paddle number input with numeric keyboard
* - Emit place_live_bid (spotter path) on confirm
*/
export default function SpotterPage() {
return (
<main className="min-h-screen p-6 space-y-6">
<h1 className="text-2xl font-bold">Spotter</h1>
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
Paddle entry not yet implemented
</div>
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { create } from "zustand";
import type { Bidder } from "@storybid/shared";
interface AuthState {
token: string | null;
bidder: Bidder | null;
role: string | null;
setAuth: (token: string, bidder: Bidder, role: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: localStorage.getItem("sb_token"),
bidder: null,
role: null,
setAuth(token, bidder, role) {
localStorage.setItem("sb_token", token);
set({ token, bidder, role });
},
clearAuth() {
localStorage.removeItem("sb_token");
set({ token: null, bidder: null, role: null });
},
}));
+13
View File
@@ -0,0 +1,13 @@
import { create } from "zustand";
export type ConnectivityStatus = "connected" | "local" | "offline";
interface ConnectivityState {
status: ConnectivityStatus;
setStatus: (status: ConnectivityStatus) => void;
}
export const useConnectivityStore = create<ConnectivityState>((set) => ({
status: navigator.onLine ? "connected" : "offline",
setStatus: (status) => set({ status }),
}));