From 884043cf2212268f544fc5205064ad23a15665a2 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 4 May 2026 14:23:10 -0500 Subject: [PATCH] Phase 3 and 4 --- README.md | 4 +- packages/client/public/icons/icon.svg | 5 + packages/client/src/App.tsx | 19 +- .../src/components/ConnectivityBanner.tsx | 52 ++- packages/client/src/hooks/useEventContext.ts | 73 ++++ packages/client/src/hooks/useLiveAuction.ts | 4 +- packages/client/src/hooks/useOfflineBids.ts | 50 ++- packages/client/src/hooks/useSilentAuction.ts | 131 +++++- packages/client/src/lib/api.ts | 17 +- packages/client/src/lib/connection-manager.ts | 156 +++++++ packages/client/src/lib/socket.ts | 67 ++- packages/client/src/pages/bidder/ItemPage.tsx | 403 +++++++++++++++++- .../client/src/pages/bidder/MyBidsPage.tsx | 152 ++++++- .../client/src/pages/bidder/SilentPage.tsx | 401 +++++++++++++---- .../src/pages/staff/SilentControlPage.tsx | 333 +++++++++++++++ packages/client/src/store/connectivity.ts | 24 ++ packages/client/vite.config.ts | 3 +- packages/server/src/routes/auctions.ts | 45 ++ packages/server/src/routes/bidders.ts | 28 +- packages/server/src/services/bid-engine.ts | 11 +- packages/server/src/services/scheduler.ts | 18 + packages/server/src/socket/index.ts | 7 + packages/server/src/socket/silent-auction.ts | 8 + packages/shared/src/types/bid.ts | 11 + 24 files changed, 1847 insertions(+), 175 deletions(-) create mode 100644 packages/client/public/icons/icon.svg create mode 100644 packages/client/src/hooks/useEventContext.ts create mode 100644 packages/client/src/lib/connection-manager.ts create mode 100644 packages/client/src/pages/staff/SilentControlPage.tsx diff --git a/README.md b/README.md index ca2693f..8de21a5 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The full product specification lives in [`STORYBID.md`](./STORYBID.md). |-------|------------------------------------------------|---------| | 1 | Foundation – monorepo, auth, org/event models | ✅ done | | 2 | Live Auction – auctioneer console, bidder view | ✅ done | -| 3 | Silent Auction – catalog, timers, outbid | ⬜ todo | -| 4 | Offline Resilience – PWA, outbox, failover | ⬜ todo | +| 3 | Silent Auction – catalog, timers, outbid | ✅ done | +| 4 | Offline Resilience – PWA, outbox, failover | ✅ done | | 5 | Event Ops – check-in, checkout, fund-a-need | ⬜ todo | | 6 | Hardening – load test, a11y, backups, docs | ⬜ todo | diff --git a/packages/client/public/icons/icon.svg b/packages/client/public/icons/icon.svg new file mode 100644 index 0000000..d86aaf9 --- /dev/null +++ b/packages/client/public/icons/icon.svg @@ -0,0 +1,5 @@ + + + SB + diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 4a36240..e41b716 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,4 +1,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; +import { useEffect } from "react"; +import { connectionManager } from "./lib/connection-manager.js"; +import { useAuthStore } from "./store/auth.js"; // Layouts import BidderLayout from "./components/BidderLayout.js"; @@ -22,6 +25,7 @@ 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"; +import SilentControlPage from "./pages/staff/SilentControlPage.js"; // Admin pages import AdminDashboard from "./pages/admin/DashboardPage.js"; @@ -33,6 +37,12 @@ import AdminReportingPage from "./pages/admin/ReportingPage.js"; import FundANeedPage from "./pages/admin/FundANeedPage.js"; export default function App() { + const token = useAuthStore((s) => s.token); + + useEffect(() => { + void connectionManager.init(); + }, [token]); + return ( {/* ── Auth (no layout) ── */} @@ -40,10 +50,11 @@ export default function App() { } /> {/* ── Staff tools (full-screen, no layout chrome) ── */} - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> {/* ── Bidder shell ── */} }> diff --git a/packages/client/src/components/ConnectivityBanner.tsx b/packages/client/src/components/ConnectivityBanner.tsx index de019b9..33cd821 100644 --- a/packages/client/src/components/ConnectivityBanner.tsx +++ b/packages/client/src/components/ConnectivityBanner.tsx @@ -1,26 +1,62 @@ /** - * Thin connectivity banner — used on pages outside the BidderLayout shell - * (auth pages, staff tools, display board). + * Connectivity banner — shown below the header on non-connected states. * - * Hidden when fully connected. + * Displays: + * - Connection path: public, local-LAN, or offline + * - Outbox count: how many bids are queued for sync */ import { useConnectivityStore } from "../store/connectivity.js"; const CONFIGS = { - local: { bg: "bg-gold-500", text: "Local network — offline-capable" }, - offline: { bg: "bg-red-500", text: "Offline — bids will sync when reconnected" }, + local: { + bg: "bg-gold-500", + text: "Local network — offline-capable", + icon: "🌐", + }, + offline: { + bg: "bg-red-500", + text: "Offline — bids will sync when reconnected", + icon: "📡", + }, } as const; export function ConnectivityBanner() { const status = useConnectivityStore((s) => s.status); - if (status === "connected") return null; + const outboxCount = useConnectivityStore((s) => s.outboxCount); + const localUrl = useConnectivityStore((s) => s.localUrl); + + if (status === "connected") { + // Only show a small badge if there are pending bids, even when connected + if (outboxCount > 0) { + return ( +
+ Syncing {outboxCount} queued bid{outboxCount !== 1 ? "s" : ""}… +
+ ); + } + return null; + } const cfg = CONFIGS[status as keyof typeof CONFIGS]; if (!cfg) return null; + const label = + status === "local" && localUrl + ? `Local: ${new URL(localUrl).hostname}` + : cfg.text; + return ( -
- {cfg.text} +
+
+ + {cfg.icon} {label} + + {outboxCount > 0 && ( + + {outboxCount} queued + + )} +
); } diff --git a/packages/client/src/hooks/useEventContext.ts b/packages/client/src/hooks/useEventContext.ts new file mode 100644 index 0000000..f0c46d0 --- /dev/null +++ b/packages/client/src/hooks/useEventContext.ts @@ -0,0 +1,73 @@ +/** + * Returns the active event context (eventId, silentAuctionId, liveAuctionId) + * derived from the authenticated bidder's first active enrollment. + * + * Results are cached module-level so the API is only called once per session. + */ +import { useState, useEffect } from "react"; +import { api } from "../lib/api.js"; +import type { Auction } from "@storybid/shared"; + +interface EventContext { + eventId: string | null; + silentAuctionId: string | null; + liveAuctionId: string | null; + loading: boolean; +} + +interface MeEnrollment { + eventId: string; +} + +interface MeResponse { + eventEnrollments: MeEnrollment[]; +} + +// Module-level cache — survives re-renders, cleared on page reload +let _cache: EventContext | null = null; + +export function useEventContext(): EventContext { + const [ctx, setCtx] = useState( + _cache ?? { eventId: null, silentAuctionId: null, liveAuctionId: null, loading: true }, + ); + + useEffect(() => { + if (_cache) { + setCtx(_cache); + return; + } + + void (async () => { + try { + const me = await api.get("/api/bidders/me"); + const eventId = me.eventEnrollments[0]?.eventId ?? null; + + if (!eventId) { + const empty = { eventId: null, silentAuctionId: null, liveAuctionId: null, loading: false }; + _cache = empty; + setCtx(empty); + return; + } + + const auctions = await api.get(`/api/auctions?eventId=${eventId}`); + const result: EventContext = { + eventId, + silentAuctionId: auctions.find((a) => a.type === "silent")?.id ?? null, + liveAuctionId: auctions.find((a) => a.type === "live")?.id ?? null, + loading: false, + }; + _cache = result; + setCtx(result); + } catch { + setCtx({ eventId: null, silentAuctionId: null, liveAuctionId: null, loading: false }); + } + })(); + }, []); + + return ctx; +} + +/** Clear the event context cache (call on logout). */ +export function clearEventContextCache(): void { + _cache = null; +} diff --git a/packages/client/src/hooks/useLiveAuction.ts b/packages/client/src/hooks/useLiveAuction.ts index 3a4cc2c..fad47ee 100644 --- a/packages/client/src/hooks/useLiveAuction.ts +++ b/packages/client/src/hooks/useLiveAuction.ts @@ -5,6 +5,7 @@ */ import { useState, useEffect } from "react"; import { getSocket } from "../lib/socket.js"; +import { useConnectivityStore } from "../store/connectivity.js"; import type { AuctionItem, Bid, ItemState } from "@storybid/shared"; export interface LiveAuctionState { @@ -16,6 +17,7 @@ export interface LiveAuctionState { } export function useLiveAuction(eventId: string) { + const socketVersion = useConnectivityStore((s) => s.socketVersion); const [state, setState] = useState({ currentItem: null, currentBid: null, @@ -74,7 +76,7 @@ export function useLiveAuction(eventId: string) { socket.off("item_state_changed"); socket.off("item_sold"); }; - }, [eventId]); + }, [eventId, socketVersion]); const placeBid = (itemId: string, amount: number, deviceId: string, clientSeq: number) => { const socket = getSocket(); diff --git a/packages/client/src/hooks/useOfflineBids.ts b/packages/client/src/hooks/useOfflineBids.ts index ae2f758..089bc56 100644 --- a/packages/client/src/hooks/useOfflineBids.ts +++ b/packages/client/src/hooks/useOfflineBids.ts @@ -1,9 +1,11 @@ /** * 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. + * - Adds bids to the outbox when offline. + * - On socket reconnect (and on `navigator.online`), flushes the outbox via + * `sync_outbox` and removes acknowledged entries. + * - Tracks the outbox count in the connectivity store so the UI can display it. + * - Re-subscribes when socketVersion increments (endpoint failover). */ import { useEffect, useCallback } from "react"; import { v4 as uuidv4 } from "uuid"; @@ -27,6 +29,14 @@ let clientSeq = 0; export function useOfflineBids() { const setStatus = useConnectivityStore((s) => s.setStatus); + const setOutboxCount = useConnectivityStore((s) => s.setOutboxCount); + // Re-subscribe when the socket switches endpoints + const socketVersion = useConnectivityStore((s) => s.socketVersion); + + const refreshOutboxCount = useCallback(async () => { + const count = await db.outbox.count(); + setOutboxCount(count); + }, [setOutboxCount]); const syncOutbox = useCallback(async () => { const pending = await db.outbox.toArray(); @@ -51,14 +61,15 @@ export function useOfflineBids() { useEffect(() => { const socket = getSocket(); - // Listen for sync results and clear acknowledged entries - const onSyncResult = (result: { localId: string; accepted: boolean }) => { + const onSyncResult = async (result: { localId: string; accepted: boolean }) => { if (result.accepted) { - void db.outbox.delete(result.localId); + await db.outbox.delete(result.localId); + await refreshOutboxCount(); } + // Failed syncs remain in the outbox for the next reconnect attempt }; - const onReconnect = () => { + const onConnect = () => { setStatus("connected"); void syncOutbox(); }; @@ -68,31 +79,37 @@ export function useOfflineBids() { }; const onOnline = () => { - setStatus("connected"); void syncOutbox(); }; const onOffline = () => setStatus("offline"); socket.on("bid_sync_result", onSyncResult); - socket.on("connect", onReconnect); + socket.on("connect", onConnect); socket.on("disconnect", onDisconnect); window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); + // Sync immediately if already connected when this effect runs + if (socket.connected) { + void syncOutbox(); + } + return () => { socket.off("bid_sync_result", onSyncResult); - socket.off("connect", onReconnect); + socket.off("connect", onConnect); socket.off("disconnect", onDisconnect); window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; - }, [setStatus, syncOutbox]); + // socketVersion triggers re-subscription after endpoint switch + }, [setStatus, syncOutbox, refreshOutboxCount, socketVersion]); + + // Keep outbox count current on mount + useEffect(() => { + void refreshOutboxCount(); + }, [refreshOutboxCount]); - /** - * 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 => { const entry: OutboxBid = { @@ -107,9 +124,10 @@ export function useOfflineBids() { lastAttemptAt: null, }; await db.outbox.add(entry); + await refreshOutboxCount(); return entry.localId; }, - [], + [refreshOutboxCount], ); return { queueBid, syncOutbox, getDeviceId }; diff --git a/packages/client/src/hooks/useSilentAuction.ts b/packages/client/src/hooks/useSilentAuction.ts index afe2833..760c59d 100644 --- a/packages/client/src/hooks/useSilentAuction.ts +++ b/packages/client/src/hooks/useSilentAuction.ts @@ -1,31 +1,91 @@ /** * 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. + * + * Manages: + * - Item list with live bid updates + * - Outbid item tracking (personal socket room) + * - Window close-time map (for countdown timers) + * - Closing-soon window set (< 5 min remaining) + * - Watchlist persisted to localStorage + * - Offline bid queuing via IndexedDB outbox */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { getSocket } from "../lib/socket.js"; import { useOfflineBids } from "./useOfflineBids.js"; import { useAuthStore } from "../store/auth.js"; +import { useConnectivityStore } from "../store/connectivity.js"; import type { AuctionItem } from "@storybid/shared"; +const WATCHLIST_KEY = "sb_watchlist"; +const CLOSING_SOON_MS = 5 * 60 * 1000; + +function loadWatchlist(): Set { + try { + const raw = localStorage.getItem(WATCHLIST_KEY); + return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); + } catch { + return new Set(); + } +} + +function saveWatchlist(set: Set): void { + localStorage.setItem(WATCHLIST_KEY, JSON.stringify([...set])); +} + +export function formatCountdown(closesAt: Date): string { + const ms = closesAt.getTime() - Date.now(); + if (ms <= 0) return "Closed"; + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + if (mins >= 60) { + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + return `${hours}h ${remMins}m`; + } + if (mins >= 5) return `${mins}m`; + return `${mins}:${String(secs).padStart(2, "0")}`; +} + export function useSilentAuction(eventId: string) { const [items, setItems] = useState([]); const [outbidItemIds, setOutbidItemIds] = useState>(new Set()); - const bidderId = useAuthStore((s) => s.bidder?.id); - const { queueBid, getDeviceId } = useOfflineBids(); + // windowId → Date close time + const [windowCloseMap, setWindowCloseMap] = useState>(new Map()); + // windowIds that are closing within 5 minutes + const [closingSoonWindowIds, setClosingSoonWindowIds] = useState>(new Set()); + const [watchlist, setWatchlist] = useState>(loadWatchlist); + // tick forces countdown re-renders every second when any window is open + const [tick, setTick] = useState(0); - let clientSeq = 0; + const bidderId = useAuthStore((s) => s.bidder?.id); + const socketVersion = useConnectivityStore((s) => s.socketVersion); + const { queueBid, getDeviceId } = useOfflineBids(); + const clientSeqRef = useRef(0); + + // Tick every second while windows are open for countdown displays + useEffect(() => { + if (windowCloseMap.size === 0) return; + const id = setInterval(() => { + setTick((t) => t + 1); + // Recompute closing-soon set + const soon = new Set(); + for (const [wid, closeAt] of windowCloseMap) { + const ms = closeAt.getTime() - Date.now(); + if (ms > 0 && ms <= CLOSING_SOON_MS) soon.add(wid); + } + setClosingSoonWindowIds(soon); + }, 1000); + return () => clearInterval(id); + }, [windowCloseMap]); useEffect(() => { + if (!eventId) return; 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 + setItems((prev) => prev.map((i) => (i.id === item.id ? item : i))); if (item.currentHighBidderId === bidderId) { setOutbidItemIds((prev) => { const next = new Set(prev); @@ -45,20 +105,38 @@ export function useSilentAuction(eventId: string) { ); }); + socket.on("silent_window_closing", ({ windowId, closesAt }) => { + setWindowCloseMap((prev) => { + const next = new Map(prev); + next.set(windowId, new Date(closesAt)); + return next; + }); + }); + + socket.on("silent_window_extended", ({ windowId, newClosesAt }) => { + setWindowCloseMap((prev) => { + const next = new Map(prev); + next.set(windowId, new Date(newClosesAt)); + return next; + }); + }); + return () => { socket.emit("leave_event", eventId); socket.off("silent_bid_accepted"); socket.off("silent_outbid"); socket.off("silent_item_closed"); + socket.off("silent_window_closing"); + socket.off("silent_window_extended"); }; - }, [eventId, bidderId]); + }, [eventId, bidderId, socketVersion]); const placeSilentBid = useCallback( async (itemId: string, amount: number) => { if (!bidderId) return; const socket = getSocket(); const deviceId = getDeviceId(); - const seq = ++clientSeq; + const seq = ++clientSeqRef.current; if (socket.connected) { socket.emit("place_silent_bid", { @@ -69,12 +147,37 @@ export function useSilentAuction(eventId: string) { clientCreatedAt: new Date().toISOString(), }); } else { - // Offline – write to IndexedDB outbox await queueBid(itemId, bidderId, amount); } }, [bidderId, getDeviceId, queueBid], ); - return { items, setItems, outbidItemIds, placeSilentBid }; + const toggleWatchlist = useCallback((itemId: string) => { + setWatchlist((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + next.add(itemId); + } + saveWatchlist(next); + return next; + }); + }, []); + + // Expose tick so countdown-displaying components can key off it + void tick; + + return { + items, + setItems, + outbidItemIds, + windowCloseMap, + closingSoonWindowIds, + watchlist, + toggleWatchlist, + placeSilentBid, + formatCountdown, + }; } diff --git a/packages/client/src/lib/api.ts b/packages/client/src/lib/api.ts index 0cd41d0..67cf51b 100644 --- a/packages/client/src/lib/api.ts +++ b/packages/client/src/lib/api.ts @@ -1,7 +1,9 @@ /** - * Thin fetch wrapper – attaches the auth token, handles JSON, and throws - * typed errors. All API modules import from here. + * Thin fetch wrapper — attaches the auth token, origin-mode header, + * and (when in local-LAN mode) the correct base URL. + * All API modules import from here. */ +import { connectionManager } from "./connection-manager.js"; export class ApiError extends Error { constructor( @@ -26,10 +28,17 @@ export async function apiFetch( headers.set("Content-Type", "application/json"); if (token) headers.set("Authorization", `Bearer ${token}`); - const res = await fetch(path, { ...init, headers }); + // Tag every request with the current network path for audit-log origin tagging + headers.set("x-origin-mode", connectionManager.getOriginMode()); + + // Use the active base URL (empty string = relative = same origin) + const base = connectionManager.getApiBaseUrl(); + const url = base ? `${base}${path}` : path; + + const res = await fetch(url, { ...init, headers }); if (!res.ok) { - const body = await res.json().catch(() => ({})) as { error?: string }; + const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new ApiError(res.status, body.error ?? res.statusText); } diff --git a/packages/client/src/lib/connection-manager.ts b/packages/client/src/lib/connection-manager.ts new file mode 100644 index 0000000..0070eb1 --- /dev/null +++ b/packages/client/src/lib/connection-manager.ts @@ -0,0 +1,156 @@ +/** + * Connection manager — handles endpoint failover between the public FQDN + * and the local event-LAN hostname when WAN access is lost. + * + * Strategy: + * 1. Try same-origin / public FQDN (default) + * 2. On socket disconnect, health-check the public URL + * 3. If public is unreachable, try the local URL (from org settings) + * 4. Update connectivity store so all UI reacts to the endpoint change + * 5. Increment socketVersion so hooks re-subscribe on the new socket + */ +import { useConnectivityStore } from "../store/connectivity.js"; + +const LOCAL_URL_KEY = "sb_local_url"; +const HEALTH_TIMEOUT_MS = 3_000; + +async function healthCheck(baseUrl: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); + try { + const url = baseUrl ? `${baseUrl}/health` : "/health"; + const token = localStorage.getItem("sb_token"); + const res = await fetch(url, { + signal: controller.signal, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return res.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +class ConnectionManager { + /** Active endpoint: "public" = same origin, "local" = LAN URL */ + private _mode: "public" | "local" = "public"; + /** The local LAN server URL e.g. "http://auction.event.lan:3001" */ + private _localUrl: string | null = null; + /** Prevent concurrent probing races */ + private _probing = false; + + constructor() { + const stored = localStorage.getItem(LOCAL_URL_KEY); + if (stored) this._localUrl = stored; + } + + // ── Initialisation ──────────────────────────────────────────────────────── + + /** + * Call once on app startup (after auth is available). + * Fetches org settings to learn the local hostname and caches it. + */ + async init(): Promise { + const token = localStorage.getItem("sb_token"); + if (!token) return; + + try { + const res = await fetch("/api/organization", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + + const org = (await res.json()) as { localHostname?: string | null }; + if (org.localHostname) { + const url = org.localHostname.startsWith("http") + ? org.localHostname + : `http://${org.localHostname}:3001`; + this._localUrl = url; + localStorage.setItem(LOCAL_URL_KEY, url); + useConnectivityStore.getState().setLocalUrl(url); + } + } catch { + // Best-effort — don't block app startup + } + } + + // ── Endpoint queries (called by socket.ts and api.ts) ───────────────────── + + /** + * Returns the local URL when in local mode, or `undefined` for same-origin. + * Used by socket.ts to create the socket pointing at the right server. + */ + getSocketUrl(): string | undefined { + return this._mode === "local" && this._localUrl ? this._localUrl : undefined; + } + + /** + * Returns the base URL prefix for API requests, or "" for same-origin. + * Used by api.ts to prefix fetch paths. + */ + getApiBaseUrl(): string { + return this._mode === "local" && this._localUrl ? this._localUrl : ""; + } + + /** + * Returns the origin-mode header value for the current endpoint. + */ + getOriginMode(): "public" | "local_dns" | "local_ip" { + return this._mode === "local" ? "local_dns" : "public"; + } + + // ── Disconnect handler (called by socket.ts) ───────────────────────────── + + /** + * Called when the socket disconnects. Probes both endpoints and updates + * the connectivity store and socketVersion as appropriate. + */ + async handleDisconnect(): Promise { + if (this._probing) return; + this._probing = true; + + const store = useConnectivityStore.getState(); + + try { + // 1. Try public endpoint (same origin) + if (await healthCheck("")) { + // Public is fine — probably a transient socket blip + store.setStatus("connected"); + this._mode = "public"; + return; + } + + // 2. Try local LAN endpoint + if (this._localUrl && (await healthCheck(this._localUrl))) { + this._mode = "local"; + store.setLocalUrl(this._localUrl); + store.setStatus("local"); + // Signal hooks to re-subscribe on the new socket URL + store.incrementSocketVersion(); + return; + } + + // 3. Both unreachable + this._mode = "public"; // reset so we try public again on reconnect + store.setStatus("offline"); + } finally { + this._probing = false; + } + } + + /** + * Called when the socket connects successfully. + * Resets back to public mode if we were previously in local. + */ + handleConnect(fromUrl?: string): void { + const isLocal = + fromUrl != null && this._localUrl != null && fromUrl.startsWith(this._localUrl); + + useConnectivityStore + .getState() + .setStatus(isLocal ? "local" : "connected"); + } +} + +export const connectionManager = new ConnectionManager(); diff --git a/packages/client/src/lib/socket.ts b/packages/client/src/lib/socket.ts index 00dc772..2a0f2db 100644 --- a/packages/client/src/lib/socket.ts +++ b/packages/client/src/lib/socket.ts @@ -1,44 +1,71 @@ +/** + * Socket.io singleton with LAN-failover support. + * + * getSocket() returns the current socket, creating one if needed. + * When the connection manager switches to the local-LAN endpoint, it + * increments `socketVersion` in the connectivity store. Hooks that + * depend on `socketVersion` will re-run their effects, calling getSocket() + * again to get the new socket pointed at the local URL. + */ import { io, type Socket } from "socket.io-client"; import type { ServerToClientEvents, ClientToServerEvents, } from "@storybid/shared"; +import { connectionManager } from "./connection-manager.js"; export type AppSocket = Socket; let socket: AppSocket | null = null; +let activeSocketUrl: string | undefined = undefined; -/** - * 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 +function createSocket(url: string | undefined): AppSocket { + const opts = { + auth: { token: localStorage.getItem("sb_token") ?? "" }, reconnectionAttempts: Infinity, reconnectionDelay: 500, - reconnectionDelayMax: 5000, + reconnectionDelayMax: 5_000, + path: "/socket.io", + }; + + const s: AppSocket = url ? io(url, opts) : io(opts); + + s.on("connect", () => { + console.log("[socket] connected", url ?? "same-origin"); + connectionManager.handleConnect(url); }); - socket.on("connect", () => { - console.log("[socket] connected via", socket?.io.engine.transport.name); - }); - - socket.on("disconnect", (reason) => { + s.on("disconnect", (reason) => { console.warn("[socket] disconnected:", reason); + void connectionManager.handleDisconnect(); }); + return s; +} + +/** + * Returns the singleton socket, creating or replacing it when the + * connection manager has switched to a new endpoint URL. + */ +export function getSocket(): AppSocket { + const targetUrl = connectionManager.getSocketUrl(); + + // Rebuild the socket if the target URL has changed + if (socket && targetUrl !== activeSocketUrl) { + socket.disconnect(); + socket = null; + } + + if (!socket) { + activeSocketUrl = targetUrl; + socket = createSocket(targetUrl); + } + return socket; } export function disconnectSocket(): void { socket?.disconnect(); socket = null; + activeSocketUrl = undefined; } diff --git a/packages/client/src/pages/bidder/ItemPage.tsx b/packages/client/src/pages/bidder/ItemPage.tsx index d4089cd..1a490fc 100644 --- a/packages/client/src/pages/bidder/ItemPage.tsx +++ b/packages/client/src/pages/bidder/ItemPage.tsx @@ -1,23 +1,396 @@ /** - * 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 + * Silent auction item detail page. + * Shows media gallery, description, a place-bid form with offline fallback, + * bid history (amounts only — no other paddle numbers), and a countdown timer. */ -import { useParams } from "react-router-dom"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, Link } from "react-router-dom"; +import { api } from "../../lib/api.js"; +import { getSocket } from "../../lib/socket.js"; +import { useOfflineBids } from "../../hooks/useOfflineBids.js"; +import { useAuthStore } from "../../store/auth.js"; +import { formatCountdown } from "../../hooks/useSilentAuction.js"; +import type { AuctionItem, ItemMedia, SilentAuctionWindow } from "@storybid/shared"; -export default function ItemPage() { - const { id } = useParams<{ id: string }>(); +interface BidSummary { + id: string; + amount: number; + isWinning: boolean; + createdAt: string; + isMine: boolean; +} + +interface ItemDetail extends AuctionItem { + media: ItemMedia[]; + bids: BidSummary[]; +} + +// ── Media gallery ───────────────────────────────────────────────────────────── +function MediaGallery({ media }: { media: ItemMedia[] }) { + const [active, setActive] = useState(0); + const images = media.filter((m) => m.mediaType === "image"); + const docs = media.filter((m) => m.mediaType === "document"); + const embeds = media.filter((m) => m.mediaType === "embed" || m.mediaType === "video"); + + if (media.length === 0) return null; return ( -
-

Item #{id}

-
- Item detail — not yet implemented -
+
+ {/* Main image */} + {images.length > 0 && ( +
+ {images[active]?.caption + {images[active]?.caption && ( +

+ {images[active].caption} +

+ )} +
+ )} + + {/* Thumbnail strip */} + {images.length > 1 && ( +
+ {images.map((m, i) => ( + + ))} +
+ )} + + {/* Embeds / video links */} + {embeds.map((m) => ( + + ▶ {m.caption ?? "Watch video"} + + ))} + + {/* Document links */} + {docs.map((m) => ( + + 📄 {m.caption ?? "View document"} + + ))} +
+ ); +} + +// ── Countdown display ───────────────────────────────────────────────────────── +function WindowTimer({ window }: { window: SilentAuctionWindow }) { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, []); + const closeAt = new Date(window.closesAt); + const text = formatCountdown(closeAt); + const msLeft = closeAt.getTime() - Date.now(); + const isSoon = msLeft > 0 && msLeft <= 5 * 60 * 1000; + + return ( +
+ {window.name} + + {isSoon ? "⏰ " : ""}{text} + +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export default function ItemPage() { + const { id } = useParams<{ id: string }>(); + const bidderId = useAuthStore((s) => s.bidder?.id); + const { queueBid, getDeviceId } = useOfflineBids(); + const clientSeqRef = useRef(0); + + const [item, setItem] = useState(null); + const [window, setWindow] = useState(null); + const [isOutbid, setIsOutbid] = useState(false); + const [bidding, setBidding] = useState(false); + const [bidError, setBidError] = useState(null); + const [bidSuccess, setBidSuccess] = useState(false); + + useEffect(() => { + if (!id) return; + api + .get(`/api/items/${id}`) + .then(async (data) => { + setItem(data); + // Check if I'm the current high bidder; if not and I have a bid, I'm outbid + const myBids = data.bids.filter((b) => b.isMine); + if (myBids.length > 0 && !data.bids.some((b) => b.isMine && b.isWinning)) { + setIsOutbid(true); + } + if (data.silentWindowId) { + const win = await api.get( + `/api/auctions/${data.auctionId}/windows/${data.silentWindowId}`, + ).catch(() => null); + // Fallback: fetch all windows and find by id + if (!win) { + const wins = await api + .get(`/api/auctions/${data.auctionId}/windows`) + .catch(() => [] as SilentAuctionWindow[]); + const found = wins.find((w) => w.id === data.silentWindowId) ?? null; + setWindow(found); + } else { + setWindow(win); + } + } + }) + .catch(console.error); + }, [id]); + + // Live bid updates via socket + useEffect(() => { + if (!item) return; + const socket = getSocket(); + + socket.on("silent_bid_accepted", ({ item: updated }) => { + if (updated.id !== id) return; + setItem((prev) => prev ? { ...prev, ...updated } : prev); + if (bidderId && updated.currentHighBidderId !== bidderId) { + setIsOutbid(true); + } else { + setIsOutbid(false); + } + }); + + socket.on("silent_outbid", ({ itemId }) => { + if (itemId === id) setIsOutbid(true); + }); + + socket.on("silent_item_closed", ({ itemId }) => { + if (itemId === id) setItem((prev) => prev ? { ...prev, state: "closed" } : prev); + }); + + socket.on("silent_window_extended", ({ windowId, newClosesAt }) => { + if (window?.id === windowId) { + setWindow((prev) => prev ? { ...prev, closesAt: newClosesAt } : prev); + } + }); + + return () => { + socket.off("silent_bid_accepted"); + socket.off("silent_outbid"); + socket.off("silent_item_closed"); + socket.off("silent_window_extended"); + }; + }, [id, bidderId, item, window]); + + const handleBid = useCallback(async () => { + if (!item || !bidderId) return; + const minNext = + item.currentHighBid != null + ? item.currentHighBid + item.bidIncrement + : item.openingBid; + + setBidding(true); + setBidError(null); + setBidSuccess(false); + + const socket = getSocket(); + const deviceId = getDeviceId(); + const seq = ++clientSeqRef.current; + + if (socket.connected) { + socket.emit("place_silent_bid", { + itemId: item.id, + amount: minNext, + deviceId, + clientSeq: seq, + clientCreatedAt: new Date().toISOString(), + }); + setBidSuccess(true); + } else { + try { + await queueBid(item.id, bidderId, minNext); + setBidSuccess(true); + } catch { + setBidError("Failed to queue bid. Please try again."); + } + } + setBidding(false); + }, [item, bidderId, getDeviceId, queueBid, clientSeqRef]); + + if (!item) { + return ( +
+ 🏷️ +

Loading item…

+
+ ); + } + + const isClosed = item.state === "closed" || item.state === "passed"; + const minNext = + item.currentHighBid != null + ? item.currentHighBid + item.bidIncrement + : item.openingBid; + + return ( +
+ {/* Back link */} + + ← Silent Auction + + + {/* Outbid banner */} + {isOutbid && !isClosed && ( +
+ ⚡ You've been outbid on this item! +
+ )} + + {/* Media */} + + + {/* Item info */} +
+
+
+

+ Lot {item.lotNumber} +

+

{item.title}

+
+ + {isClosed ? "Closed" : "Open"} + +
+ + {item.donorName && ( +

+ Donated by {item.donorName} +

+ )} + + {item.description && ( +

{item.description}

+ )} + + {item.fairMarketValue != null && ( +

+ Fair market value: ${item.fairMarketValue.toLocaleString()} +

+ )} +
+ + {/* Window countdown */} + {window && window.status === "open" && } + + {/* Bid section */} +
+
+
+

Current bid

+

+ {item.currentHighBid != null + ? `$${item.currentHighBid.toLocaleString()}` + : `$${item.openingBid.toLocaleString()} start`} +

+
+ {item.currentHighBid != null && ( +

+ Next bid
+ ${minNext.toLocaleString()} +

+ )} +
+ + {bidSuccess && ( +

+ ✓ Bid placed! +

+ )} + {bidError && ( +

{bidError}

+ )} + + {!isClosed && ( + + )} + + {isClosed && item.currentHighBidderId === bidderId && ( +
+ 🎉 You won this item! +
+ )} +
+ + {/* Bid history */} + {item.bids.length > 0 && ( +
+

Bid history

+
    + {item.bids.map((b) => ( +
  • +
    + + ${Number(b.amount).toLocaleString()} + + {b.isMine && ( + + Mine + + )} + {b.isWinning && ( + High bid + )} +
    + + {new Date(b.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
  • + ))} +
+
+ )} + + {item.pickupNotes && ( +
+

Pickup notes

+

{item.pickupNotes}

+
+ )}
); } diff --git a/packages/client/src/pages/bidder/MyBidsPage.tsx b/packages/client/src/pages/bidder/MyBidsPage.tsx index e5e56e5..26710a0 100644 --- a/packages/client/src/pages/bidder/MyBidsPage.tsx +++ b/packages/client/src/pages/bidder/MyBidsPage.tsx @@ -1,14 +1,152 @@ /** - * Bidder's personal bid history and watchlist. - * TODO: fetch /api/bidders/me/bids, show winning / outbid status per item. + * Bidder's personal bid history. + * Shows every item bid on with winning / outbid / closed status. */ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../../lib/api.js"; +import { useAuthStore } from "../../store/auth.js"; +import type { BidWithItem } from "@storybid/shared"; + +type BidStatus = "winning" | "outbid" | "won" | "missed"; + +function itemBidStatus(bid: BidWithItem): BidStatus { + const closed = bid.item.state === "closed" || bid.item.state === "sold" || bid.item.state === "passed"; + if (!closed) { + return bid.isWinning ? "winning" : "outbid"; + } + if (bid.item.state === "passed") return "missed"; + return bid.item.currentHighBidderId === bid.bidderId ? "won" : "missed"; +} + +const STATUS_META: Record = { + winning: { label: "Winning", color: "bg-emerald-100 text-emerald-700" }, + outbid: { label: "Outbid", color: "bg-red-100 text-red-600" }, + won: { label: "Won", color: "bg-brand-100 text-brand-700" }, + missed: { label: "Missed", color: "bg-gray-100 text-gray-400" }, +}; + export default function MyBidsPage() { - return ( -
-

My Bids

-
- Bid history — not yet implemented + const bidder = useAuthStore((s) => s.bidder); + const [bids, setBids] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!bidder?.id) return; + api + .get(`/api/bidders/${bidder.id}/bids`) + .then((all) => { + // Deduplicate: keep only the most recent bid per item + const byItem = new Map(); + for (const b of all) { + const existing = byItem.get(b.itemId); + if (!existing || new Date(b.createdAt) > new Date(existing.createdAt)) { + byItem.set(b.itemId, b); + } + } + setBids([...byItem.values()].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, [bidder?.id]); + + if (loading) { + return ( +
+ 📋 +

Loading your bids…

+ ); + } + + const winning = bids.filter((b) => itemBidStatus(b) === "winning"); + const won = bids.filter((b) => itemBidStatus(b) === "won"); + const outbid = bids.filter((b) => itemBidStatus(b) === "outbid"); + const missed = bids.filter((b) => itemBidStatus(b) === "missed"); + + return ( +
+

My Bids

+ + {bids.length === 0 ? ( +
+ No bids placed yet.{" "} + + Browse the silent auction → + +
+ ) : ( + <> + {/* Summary strip */} +
+ {[ + { label: "Winning", count: winning.length, color: "text-emerald-600" }, + { label: "Won", count: won.length, color: "text-brand-700" }, + { label: "Outbid", count: outbid.length, color: "text-red-500" }, + { label: "Missed", count: missed.length, color: "text-gray-400" }, + ].map(({ label, count, color }) => ( +
+

{count}

+

{label}

+
+ ))} +
+ + {/* Bid list */} +
    + {bids.map((bid) => { + const status = itemBidStatus(bid); + const meta = STATUS_META[status]; + return ( +
  • +
    +
    +
    +

    Lot {bid.item.lotNumber}

    + + {bid.item.title} + +
    + {meta.label} +
    + +
    +
    +

    Your last bid

    +

    + ${Number(bid.amount).toLocaleString()} +

    +
    + {bid.item.currentHighBid != null && !bid.isWinning && ( +
    +

    Current high

    +

    + ${bid.item.currentHighBid.toLocaleString()} +

    +
    + )} +
    + + {status === "outbid" && ( + + Bid again → + + )} +
    +
  • + ); + })} +
+ + )}
); } diff --git a/packages/client/src/pages/bidder/SilentPage.tsx b/packages/client/src/pages/bidder/SilentPage.tsx index f503cf9..f3d8ce1 100644 --- a/packages/client/src/pages/bidder/SilentPage.tsx +++ b/packages/client/src/pages/bidder/SilentPage.tsx @@ -1,102 +1,353 @@ /** * Silent auction catalog. - * Loads items from the API, then keeps them live via Socket.io. - * Outbid items are highlighted; offline bids queue to IndexedDB. + * + * Groups items by window, shows live countdown timers, highlights outbid items, + * supports a watchlist (localStorage), and queues bids offline via IndexedDB. */ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { useSilentAuction } from "../../hooks/useSilentAuction.js"; +import { useSilentAuction, formatCountdown } from "../../hooks/useSilentAuction.js"; +import { useEventContext } from "../../hooks/useEventContext.js"; import { api } from "../../lib/api.js"; -import type { AuctionItem } from "@storybid/shared"; +import type { AuctionItem, SilentAuctionWindow } from "@storybid/shared"; -interface Props { - eventId: string; - auctionId: string; +type FilterTab = "all" | "watching" | "open"; + +// ── Countdown badge ──────────────────────────────────────────────────────────── +function CountdownBadge({ + windowId, + windowCloseMap, + closingSoonWindowIds, +}: { + windowId: string | null; + windowCloseMap: Map; + closingSoonWindowIds: Set; +}) { + if (!windowId) return null; + const closeAt = windowCloseMap.get(windowId); + if (!closeAt) return null; + + const isSoon = closingSoonWindowIds.has(windowId); + const text = formatCountdown(closeAt); + + return ( + + {isSoon && "⏰ "} + {text} + + ); } -export default function SilentPage({ eventId, auctionId }: Props) { - const { items, setItems, outbidItemIds, placeSilentBid } = useSilentAuction(eventId); +// ── Item card ───────────────────────────────────────────────────────────────── +function ItemCard({ + item, + isOutbid, + isWatched, + windowCloseMap, + closingSoonWindowIds, + onBid, + onToggleWatch, +}: { + item: AuctionItem; + isOutbid: boolean; + isWatched: boolean; + windowCloseMap: Map; + closingSoonWindowIds: Set; + onBid: (itemId: string, amount: number) => void; + onToggleWatch: (itemId: string) => void; +}) { + const isClosed = item.state === "closed" || item.state === "passed"; + const minNext = + item.currentHighBid != null + ? item.currentHighBid + item.bidIncrement + : item.openingBid; - // Initial load from REST catalog + return ( +
  • + {isOutbid && ( +
    + ⚡ You've been outbid! +
    + )} + +
    + {/* Header row */} +
    +

    Lot {item.lotNumber}

    +
    + + + {isClosed ? "Closed" : "Open"} + +
    +
    + + {/* Title + watchlist */} +
    + + {item.title} + + +
    + + {item.donorName && ( +

    Donated by {item.donorName}

    + )} + + {/* Bid row */} +
    +
    +

    Current bid

    +

    + {item.currentHighBid != null + ? `$${item.currentHighBid.toLocaleString()}` + : `$${item.openingBid.toLocaleString()} start`} +

    +
    + + {!isClosed && ( + + )} +
    +
    +
  • + ); +} + +// ── Window section header ───────────────────────────────────────────────────── +function WindowHeader({ + window, + itemCount, + windowCloseMap, + closingSoonWindowIds, +}: { + window: SilentAuctionWindow; + itemCount: number; + windowCloseMap: Map; + closingSoonWindowIds: Set; +}) { + const closeAt = windowCloseMap.get(window.id); + const isSoon = closingSoonWindowIds.has(window.id); + + return ( +
    +
    + +

    {window.name}

    + ({itemCount} items) +
    +
    + {closeAt && window.status === "open" && ( + + {isSoon ? "⏰ " : ""}Closes {formatCountdown(closeAt)} + + )} + {window.status === "closed" && ( + Closed + )} + {window.status === "pending" && ( + Not open yet + )} +
    +
    + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export default function SilentPage() { + const { silentAuctionId, eventId, loading: ctxLoading } = useEventContext(); + const { + items, + setItems, + outbidItemIds, + windowCloseMap, + closingSoonWindowIds, + watchlist, + toggleWatchlist, + placeSilentBid, + } = useSilentAuction(eventId ?? ""); + + const [windows, setWindows] = useState([]); + const [filter, setFilter] = useState("all"); + + // Load items useEffect(() => { + if (!silentAuctionId) return; api - .get(`/api/items?auctionId=${auctionId}`) + .get(`/api/items?auctionId=${silentAuctionId}`) .then(setItems) .catch(console.error); - }, [auctionId, setItems]); + }, [silentAuctionId, setItems]); - if (!items.length) { + // Load windows and seed close-time map + useEffect(() => { + if (!silentAuctionId) return; + api + .get(`/api/auctions/${silentAuctionId}/windows`) + .then((wins) => { + setWindows(wins); + }) + .catch(console.error); + }, [silentAuctionId]); + + if (ctxLoading) { return (
    🏷️ -

    Loading silent auction items…

    +

    Loading silent auction…

    ); } + if (!silentAuctionId) { + return ( +
    + 🏷️ +

    No silent auction found for this event.

    +
    + ); + } + + // Filter items + const visibleItems = items.filter((item) => { + if (filter === "watching") return watchlist.has(item.id); + if (filter === "open") return item.state !== "closed" && item.state !== "passed"; + return true; + }); + + // Group by window (items without a window go into a catch-all group) + const windowMap = new Map(); + for (const item of visibleItems) { + const key = item.silentWindowId ?? null; + if (!windowMap.has(key)) windowMap.set(key, []); + windowMap.get(key)!.push(item); + } + + const FILTER_TABS: { key: FilterTab; label: string }[] = [ + { key: "all", label: "All" }, + { key: "watching", label: `Watching (${watchlist.size})` }, + { key: "open", label: "Open" }, + ]; + return ( -
    -

    Silent Auction

    +
    +
    +

    Silent Auction

    + {items.length} lots +
    -
      - {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; + {/* Filter tabs */} +
      + {FILTER_TABS.map(({ key, label }) => ( + + ))} +
      - return ( -
    • - {/* Outbid banner */} - {isOutbid && ( -
      - ⚡ You've been outbid! -
      - )} - -
      -
      -

      Lot {item.lotNumber}

      - - {isClosed ? "Closed" : "Open"} - -
      - - - {item.title} - - -
      -
      -

      Current bid

      -

      - {item.currentHighBid != null - ? `$${item.currentHighBid.toLocaleString()}` - : `$${item.openingBid.toLocaleString()}`} -

      -
      - - {!isClosed && ( - - )} -
      -
      -
    • - ); - })} -
    + {visibleItems.length === 0 ? ( +
    + {filter === "watching" + ? "Star an item to add it to your watchlist." + : "No items to show."} +
    + ) : ( +
    + {windows.length > 0 + ? windows.map((win) => { + const winItems = windowMap.get(win.id) ?? []; + if (winItems.length === 0 && filter !== "all") return null; + return ( +
    + +
      + {winItems.map((item) => ( + void placeSilentBid(id, amt)} + onToggleWatch={toggleWatchlist} + /> + ))} +
    +
    + ); + }) + : /* No windows defined — show flat list */ + (() => { + const unwindowed = windowMap.get(null) ?? []; + return ( +
      + {unwindowed.map((item) => ( + void placeSilentBid(id, amt)} + onToggleWatch={toggleWatchlist} + /> + ))} +
    + ); + })()} +
    + )}
    ); } diff --git a/packages/client/src/pages/staff/SilentControlPage.tsx b/packages/client/src/pages/staff/SilentControlPage.tsx new file mode 100644 index 0000000..bf138f4 --- /dev/null +++ b/packages/client/src/pages/staff/SilentControlPage.tsx @@ -0,0 +1,333 @@ +/** + * Staff: Silent Auction Control + * + * Full-screen staff view for managing silent auction windows during an event. + * Shows all windows with live item high bids, and manual open/close controls. + */ +import { useEffect, useState, useCallback } from "react"; +import { api } from "../../lib/api.js"; +import { getSocket } from "../../lib/socket.js"; +import { formatCountdown } from "../../hooks/useSilentAuction.js"; +import type { Auction, AuctionItem, SilentAuctionWindow } from "@storybid/shared"; + +// ── Countdown with tick ─────────────────────────────────────────────────────── +function Countdown({ closesAt }: { closesAt: string }) { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, []); + const text = formatCountdown(new Date(closesAt)); + const soon = new Date(closesAt).getTime() - Date.now() < 5 * 60 * 1000; + return ( + + {text} + + ); +} + +// ── Window card ─────────────────────────────────────────────────────────────── +function WindowCard({ + win, + items, + auctionId, + onWindowUpdated, +}: { + win: SilentAuctionWindow; + items: AuctionItem[]; + auctionId: string; + onWindowUpdated: (updated: SilentAuctionWindow) => void; +}) { + const [busy, setBusy] = useState(false); + + const setStatus = useCallback( + async (status: "open" | "closed") => { + setBusy(true); + try { + const updated = await api.patch( + `/api/auctions/${auctionId}/windows/${win.id}`, + { status }, + ); + onWindowUpdated(updated); + } catch { + alert("Failed to update window status."); + } finally { + setBusy(false); + } + }, + [auctionId, win.id, onWindowUpdated], + ); + + const statusBadge = + win.status === "open" + ? "bg-emerald-100 text-emerald-700" + : win.status === "closed" + ? "bg-gray-100 text-gray-400" + : "bg-gold-100 text-gold-700"; + + const winItems = items.filter((i) => i.silentWindowId === win.id); + const openCount = winItems.filter((i) => i.state !== "closed" && i.state !== "passed").length; + const totalBids = winItems.reduce((sum, i) => sum + (i.currentHighBid ?? 0), 0); + + return ( +
    + {/* Window header */} +
    +
    + +
    +

    {win.name}

    +

    + {winItems.length} items · {openCount} open +

    +
    +
    + +
    + {win.status === "open" && ( + + )} + + {win.status === "pending" ? "Pending" : win.status === "open" ? "Open" : "Closed"} + + {win.status === "pending" && ( + + )} + {win.status === "open" && ( + + )} +
    +
    + + {/* Revenue summary */} +
    +
    +

    High bids total

    +

    + ${totalBids.toLocaleString()} +

    +
    +
    +

    Items with bids

    +

    + {winItems.filter((i) => i.currentHighBid != null).length} / {winItems.length} +

    +
    + {win.softCloseEnabled && ( +
    +

    Soft close

    +

    +{win.softCloseExtendMinutes}m

    +
    + )} +
    + + {/* Item list */} + {winItems.length > 0 ? ( +
      + {winItems.map((item) => { + const isClosed = item.state === "closed" || item.state === "passed"; + return ( +
    • +
      +

      Lot {item.lotNumber}

      +

      {item.title}

      +
      +
      + {item.currentHighBid != null ? ( +

      + ${item.currentHighBid.toLocaleString()} +

      + ) : ( +

      No bids

      + )} + {isClosed && ( +

      Closed

      + )} +
      +
    • + ); + })} +
    + ) : ( +

    No items assigned to this window.

    + )} +
    + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export default function SilentControlPage() { + const [eventId, setEventId] = useState(null); + const [auctions, setAuctions] = useState([]); + const [selectedAuctionId, setSelectedAuctionId] = useState(null); + const [windows, setWindows] = useState([]); + const [items, setItems] = useState([]); + + // Load the most relevant event (active first, then most recent) + useEffect(() => { + api + .get>("/api/events") + .then((events) => { + const active = events.find((e) => e.status === "active") ?? events[0]; + if (active) setEventId(active.id); + }) + .catch(console.error); + }, []); + + useEffect(() => { + if (!eventId) return; + api + .get(`/api/auctions?eventId=${eventId}`) + .then((all) => { + const silent = all.filter((a) => a.type === "silent"); + setAuctions(silent); + if (silent[0]) setSelectedAuctionId(silent[0].id); + }) + .catch(console.error); + }, [eventId]); + + useEffect(() => { + if (!selectedAuctionId) return; + Promise.all([ + api.get(`/api/auctions/${selectedAuctionId}/windows`), + api.get(`/api/items?auctionId=${selectedAuctionId}`), + ]) + .then(([wins, its]) => { + setWindows(wins); + setItems(its); + }) + .catch(console.error); + }, [selectedAuctionId]); + + // Live item updates + useEffect(() => { + if (!eventId) return; + 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))); + }); + + socket.on("silent_item_closed", ({ itemId }) => { + setItems((prev) => + prev.map((i) => (i.id === itemId ? { ...i, state: "closed" } : i)), + ); + }); + + socket.on("silent_window_extended", ({ windowId, newClosesAt }) => { + setWindows((prev) => + prev.map((w) => (w.id === windowId ? { ...w, closesAt: newClosesAt } : w)), + ); + }); + + return () => { + socket.emit("leave_event", eventId); + socket.off("silent_bid_accepted"); + socket.off("silent_item_closed"); + socket.off("silent_window_extended"); + }; + }, [eventId]); + + const handleWindowUpdated = useCallback((updated: SilentAuctionWindow) => { + setWindows((prev) => prev.map((w) => (w.id === updated.id ? updated : w))); + // If closed, mark items in that window as closed + if (updated.status === "closed") { + setItems((prev) => + prev.map((i) => + i.silentWindowId === updated.id && i.state !== "passed" + ? { ...i, state: "closed" } + : i, + ), + ); + } + }, []); + + const unassignedItems = items.filter((i) => !i.silentWindowId); + + return ( +
    + {/* Header */} +
    +
    +

    Staff

    +

    Silent Auction Control

    +
    + {auctions.length > 1 && ( + + )} +
    + +
    + {windows.length === 0 && !selectedAuctionId && ( +
    + No silent auction found for this event. +
    + )} + + {windows.length === 0 && selectedAuctionId && ( +
    + No windows configured for this auction. +
    + )} + + {windows + .sort((a, b) => new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime()) + .map((win) => ( + + ))} + + {unassignedItems.length > 0 && ( +
    +
    +

    Unassigned Items

    +

    {unassignedItems.length} items not in any window

    +
    +
      + {unassignedItems.map((item) => ( +
    • +
      +

      Lot {item.lotNumber}

      +

      {item.title}

      +
      +

      + {item.currentHighBid != null ? `$${item.currentHighBid.toLocaleString()}` : "—"} +

      +
    • + ))} +
    +
    + )} +
    +
    + ); +} diff --git a/packages/client/src/store/connectivity.ts b/packages/client/src/store/connectivity.ts index 9ea2e71..658e627 100644 --- a/packages/client/src/store/connectivity.ts +++ b/packages/client/src/store/connectivity.ts @@ -4,10 +4,34 @@ export type ConnectivityStatus = "connected" | "local" | "offline"; interface ConnectivityState { status: ConnectivityStatus; + // Incremented each time the socket needs to reconnect to a new URL + socketVersion: number; + // Local LAN server URL (e.g. "http://auction.event.lan:3001") + localUrl: string | null; + // How many bids are queued in the offline outbox + outboxCount: number; + setStatus: (status: ConnectivityStatus) => void; + incrementSocketVersion: () => void; + setLocalUrl: (url: string | null) => void; + setOutboxCount: (count: number) => void; } export const useConnectivityStore = create((set) => ({ status: navigator.onLine ? "connected" : "offline", + socketVersion: 0, + localUrl: localStorage.getItem("sb_local_url"), + outboxCount: 0, + setStatus: (status) => set({ status }), + incrementSocketVersion: () => set((s) => ({ socketVersion: s.socketVersion + 1 })), + setLocalUrl: (url) => { + if (url) { + localStorage.setItem("sb_local_url", url); + } else { + localStorage.removeItem("sb_local_url"); + } + set({ localUrl: url }); + }, + setOutboxCount: (outboxCount) => set({ outboxCount }), })); diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 179bb71..8a01787 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -36,8 +36,7 @@ export default defineConfig({ orientation: "portrait", start_url: "/", icons: [ - { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }, + { src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }, ], }, }), diff --git a/packages/server/src/routes/auctions.ts b/packages/server/src/routes/auctions.ts index 19b92e8..45c86e5 100644 --- a/packages/server/src/routes/auctions.ts +++ b/packages/server/src/routes/auctions.ts @@ -142,3 +142,48 @@ auctionsRouter.post("/:id/windows", requireAuth, STAFF_WRITE, async (req, res) = }); res.status(201).json(window); }); + +const UpdateWindowSchema = z.object({ + name: z.string().min(1).optional(), + opensAt: z.string().datetime().optional(), + closesAt: z.string().datetime().optional(), + softCloseEnabled: z.boolean().optional(), + softCloseExtendMinutes: z.number().int().min(1).max(60).optional(), + status: z.enum(["pending", "open", "closed"]).optional(), +}); + +auctionsRouter.patch("/:id/windows/:windowId", requireAuth, AUCTIONEER, async (req, res) => { + const parse = UpdateWindowSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + const window = await prisma.silentAuctionWindow.updateMany({ + where: { id: req.params["windowId"], auctionId: req.params["id"] }, + data: parse.data, + }); + if (window.count === 0) { + res.status(404).json({ error: "Window not found" }); + return; + } + const updated = await prisma.silentAuctionWindow.findUnique({ + where: { id: req.params["windowId"] }, + }); + res.json(updated); +}); + +auctionsRouter.delete("/:id/windows/:windowId", requireAuth, STAFF_WRITE, async (req, res) => { + const window = await prisma.silentAuctionWindow.findFirst({ + where: { id: req.params["windowId"], auctionId: req.params["id"] }, + }); + if (!window) { + res.status(404).json({ error: "Window not found" }); + return; + } + if (window.status !== "pending") { + res.status(409).json({ error: "Cannot delete a window that has been opened" }); + return; + } + await prisma.silentAuctionWindow.delete({ where: { id: window.id } }); + res.json({ ok: true }); +}); diff --git a/packages/server/src/routes/bidders.ts b/packages/server/src/routes/bidders.ts index 1ec57ef..c016dc7 100644 --- a/packages/server/src/routes/bidders.ts +++ b/packages/server/src/routes/bidders.ts @@ -259,7 +259,31 @@ biddersRouter.get("/:id/bids", requireAuth, async (req, res) => { const bids = await prisma.bid.findMany({ where: { bidderId: req.params["id"] }, orderBy: { createdAt: "desc" }, - include: { item: { select: { title: true, lotNumber: true, state: true } } }, + include: { + item: { + select: { + title: true, + lotNumber: true, + state: true, + currentHighBid: true, + currentHighBidderId: true, + }, + }, + }, }); - res.json(bids); + + // Serialize Decimal fields + const serialized = bids.map((b) => ({ + ...b, + amount: Number(b.amount), + clientCreatedAt: b.clientCreatedAt.toISOString(), + serverReceivedAt: b.serverReceivedAt.toISOString(), + createdAt: b.createdAt.toISOString(), + item: { + ...b.item, + currentHighBid: b.item.currentHighBid ? Number(b.item.currentHighBid) : null, + }, + })); + + res.json(serialized); }); diff --git a/packages/server/src/services/bid-engine.ts b/packages/server/src/services/bid-engine.ts index f84b067..d7df061 100644 --- a/packages/server/src/services/bid-engine.ts +++ b/packages/server/src/services/bid-engine.ts @@ -20,7 +20,7 @@ export interface PlaceBidInput { } export type BidResult = - | { ok: true; bid: Awaited>; item: Awaited> } + | { ok: true; bid: Awaited>; item: Awaited>; windowExtendedTo?: string } | { ok: false; error: string; code: "ITEM_NOT_FOUND" | "WINDOW_CLOSED" | "ITEM_STATE" | "AMOUNT_TOO_LOW" | "DUPLICATE" }; /** @@ -121,6 +121,7 @@ export async function placeBid(input: PlaceBidInput): Promise { }); // 8. Soft-close extension for silent auction + let windowExtendedTo: string | undefined; if ( auction.type === "silent" && updatedItem.softCloseEnabled && @@ -133,16 +134,16 @@ export async function placeBid(input: PlaceBidInput): Promise { const msRemaining = window.closesAt.getTime() - Date.now(); const extendThresholdMs = updatedItem.softCloseExtendMinutes * 60 * 1000; if (msRemaining < extendThresholdMs) { + const newClosesAt = new Date(Date.now() + extendThresholdMs); await tx.silentAuctionWindow.update({ where: { id: window.id }, - data: { - closesAt: new Date(Date.now() + extendThresholdMs), - }, + data: { closesAt: newClosesAt }, }); + windowExtendedTo = newClosesAt.toISOString(); } } } - return { ok: true, bid, item: updatedItem }; + return { ok: true, bid, item: updatedItem, windowExtendedTo }; }); } diff --git a/packages/server/src/services/scheduler.ts b/packages/server/src/services/scheduler.ts index 81b8c77..7d452be 100644 --- a/packages/server/src/services/scheduler.ts +++ b/packages/server/src/services/scheduler.ts @@ -19,6 +19,7 @@ import type { type IO = Server; const POLL_INTERVAL_MS = 10_000; +const CLOSING_WARN_MS = 5 * 60 * 1000; // warn 5 minutes before close export function startScheduler(io: IO): void { console.log("[scheduler] starting silent auction window poller"); @@ -107,6 +108,7 @@ async function closeExpiredWindows(io: IO): Promise { data: { status: "open" }, }); + // Tell clients the close time so they can start their countdowns io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", { windowId: window.id, closesAt: window.closesAt.toISOString(), @@ -114,4 +116,20 @@ async function closeExpiredWindows(io: IO): Promise { console.log(`[scheduler] opened window ${window.id} (${window.name})`); } + + // Emit a closing-soon warning for open windows within the 5-minute threshold + const closingSoonWindows = await prisma.silentAuctionWindow.findMany({ + where: { + status: "open", + closesAt: { gt: now, lte: new Date(now.getTime() + CLOSING_WARN_MS) }, + }, + include: { auction: { select: { eventId: true } } }, + }); + + for (const window of closingSoonWindows) { + io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", { + windowId: window.id, + closesAt: window.closesAt.toISOString(), + }); + } } diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts index 156f323..e79fd6c 100644 --- a/packages/server/src/socket/index.ts +++ b/packages/server/src/socket/index.ts @@ -45,6 +45,13 @@ export function registerSocketHandlers(io: IO): void { void socket.join(`bidder:${socket.data.bidderId}`); } + // Tell the client which connectivity path is active. + // The x-origin-mode handshake header is set by the client's connection manager. + const originHint = socket.handshake.headers["x-origin-mode"] as string | undefined; + const syncStatus: "connected" | "local" | "offline" = + originHint === "local_dns" || originHint === "local_ip" ? "local" : "connected"; + socket.emit("sync_status_changed", { status: syncStatus }); + // Room join/leave for event-scoped broadcasts socket.on("join_event", (eventId) => { void socket.join(`event:${eventId}`); diff --git a/packages/server/src/socket/silent-auction.ts b/packages/server/src/socket/silent-auction.ts index b8463bb..e2f5257 100644 --- a/packages/server/src/socket/silent-auction.ts +++ b/packages/server/src/socket/silent-auction.ts @@ -70,6 +70,14 @@ export function registerSilentAuctionHandlers(io: IO, socket: Sock): void { item: serializedItem, }); + // If a soft-close extension happened, tell all clients the new close time + if (result.windowExtendedTo && serializedItem.silentWindowId) { + io.to(`event:${item.auction.eventId}`).emit("silent_window_extended", { + windowId: serializedItem.silentWindowId, + newClosesAt: result.windowExtendedTo, + }); + } + // Notify the previously winning bidder that they've been outbid. // We find the second-highest bid for this item. const previousBid = await prisma.bid.findFirst({ diff --git a/packages/shared/src/types/bid.ts b/packages/shared/src/types/bid.ts index 3abd077..6062f70 100644 --- a/packages/shared/src/types/bid.ts +++ b/packages/shared/src/types/bid.ts @@ -24,6 +24,17 @@ export interface Bid { createdAt: string; } +// Bid enriched with item summary — returned by GET /api/bidders/:id/bids +export interface BidWithItem extends Bid { + item: { + title: string; + lotNumber: string; + state: string; + currentHighBid: number | null; + currentHighBidderId: string | null; + }; +} + // Outbox entry stored in IndexedDB before network sync export interface OutboxBid { localId: string; // UUID generated client-side