Phase 3 and 4

This commit is contained in:
2026-05-04 14:23:10 -05:00
parent 056bd27f89
commit 884043cf22
24 changed files with 1847 additions and 175 deletions
+2 -2
View File
@@ -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 |
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2B5916"/>
<text x="256" y="300" font-family="Georgia, serif" font-size="220" font-weight="bold"
text-anchor="middle" fill="#C4952A">SB</text>
</svg>

After

Width:  |  Height:  |  Size: 270 B

+11
View File
@@ -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 (
<Routes>
{/* ── Auth (no layout) ── */}
@@ -43,6 +53,7 @@ export default function App() {
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
<Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/staff/silent-control" element={<SilentControlPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* ── Bidder shell ── */}
@@ -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 (
<div className="bg-gold-500 text-white text-center text-xs py-1.5 px-4 font-medium">
Syncing {outboxCount} queued bid{outboxCount !== 1 ? "s" : ""}
</div>
);
}
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 (
<div className={`${cfg.bg} text-white text-center text-xs py-1.5 px-4 font-semibold tracking-wide`}>
{cfg.text}
<div className={`${cfg.bg} text-white text-xs py-1.5 px-4 font-medium`}>
<div className="flex items-center justify-between max-w-xl mx-auto">
<span>
{cfg.icon} {label}
</span>
{outboxCount > 0 && (
<span className="bg-white/20 rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums">
{outboxCount} queued
</span>
)}
</div>
</div>
);
}
@@ -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<EventContext>(
_cache ?? { eventId: null, silentAuctionId: null, liveAuctionId: null, loading: true },
);
useEffect(() => {
if (_cache) {
setCtx(_cache);
return;
}
void (async () => {
try {
const me = await api.get<MeResponse>("/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<Auction[]>(`/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;
}
+3 -1
View File
@@ -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<LiveAuctionState>({
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();
+34 -16
View File
@@ -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<string> => {
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 };
+117 -14
View File
@@ -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<string> {
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<string>): 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<AuctionItem[]>([]);
const [outbidItemIds, setOutbidItemIds] = useState<Set<string>>(new Set());
const bidderId = useAuthStore((s) => s.bidder?.id);
const { queueBid, getDeviceId } = useOfflineBids();
// windowId → Date close time
const [windowCloseMap, setWindowCloseMap] = useState<Map<string, Date>>(new Map());
// windowIds that are closing within 5 minutes
const [closingSoonWindowIds, setClosingSoonWindowIds] = useState<Set<string>>(new Set());
const [watchlist, setWatchlist] = useState<Set<string>>(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<string>();
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,
};
}
+13 -4
View File
@@ -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<T>(
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);
}
@@ -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<boolean> {
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<void> {
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<void> {
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();
+47 -20
View File
@@ -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<ServerToClientEvents, ClientToServerEvents>;
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;
}
+387 -14
View File
@@ -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 (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Item #{id}</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Item detail not yet implemented
<div className="space-y-2">
{/* Main image */}
{images.length > 0 && (
<div className="relative bg-gray-100 rounded-2xl overflow-hidden aspect-[4/3]">
<img
src={images[active]?.url}
alt={images[active]?.caption ?? "Item photo"}
className="w-full h-full object-cover"
/>
{images[active]?.caption && (
<p className="absolute bottom-0 inset-x-0 bg-black/40 text-white text-xs px-3 py-1.5">
{images[active].caption}
</p>
)}
</div>
)}
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((m, i) => (
<button
key={m.id}
onClick={() => setActive(i)}
className={`shrink-0 w-14 h-14 rounded-xl overflow-hidden border-2 transition-colors ${
i === active ? "border-brand-700" : "border-transparent"
}`}
>
<img src={m.thumbnailUrl ?? m.url} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
{/* Embeds / video links */}
{embeds.map((m) => (
<a
key={m.id}
href={m.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-brand-700 underline"
>
{m.caption ?? "Watch video"}
</a>
))}
{/* Document links */}
{docs.map((m) => (
<a
key={m.id}
href={m.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-brand-700 underline"
>
📄 {m.caption ?? "View document"}
</a>
))}
</div>
);
}
// ── 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 (
<div className={`flex items-center justify-between px-4 py-2 rounded-xl text-sm ${
isSoon ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-600"
}`}>
<span className="font-medium">{window.name}</span>
<span className={`font-bold tabular-nums ${isSoon ? "animate-pulse" : ""}`}>
{isSoon ? "⏰ " : ""}{text}
</span>
</div>
);
}
// ── 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<ItemDetail | null>(null);
const [window, setWindow] = useState<SilentAuctionWindow | null>(null);
const [isOutbid, setIsOutbid] = useState(false);
const [bidding, setBidding] = useState(false);
const [bidError, setBidError] = useState<string | null>(null);
const [bidSuccess, setBidSuccess] = useState(false);
useEffect(() => {
if (!id) return;
api
.get<ItemDetail>(`/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<SilentAuctionWindow>(
`/api/auctions/${data.auctionId}/windows/${data.silentWindowId}`,
).catch(() => null);
// Fallback: fetch all windows and find by id
if (!win) {
const wins = await api
.get<SilentAuctionWindow[]>(`/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 (
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">🏷</span>
<p className="text-gray-400 text-center">Loading item</p>
</div>
);
}
const isClosed = item.state === "closed" || item.state === "passed";
const minNext =
item.currentHighBid != null
? item.currentHighBid + item.bidIncrement
: item.openingBid;
return (
<div className="p-4 space-y-5 animate-fade-in">
{/* Back link */}
<Link to="/silent" className="inline-flex items-center gap-1 text-sm text-brand-700 font-medium">
Silent Auction
</Link>
{/* Outbid banner */}
{isOutbid && !isClosed && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm font-semibold">
You've been outbid on this item!
</div>
)}
{/* Media */}
<MediaGallery media={item.media} />
{/* Item info */}
<div className="card p-5 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Lot {item.lotNumber}
</p>
<h1 className="text-xl font-black text-gray-900 leading-tight mt-0.5">{item.title}</h1>
</div>
<span className={`badge shrink-0 ${isClosed ? "bg-gray-100 text-gray-400" : "bg-emerald-100 text-emerald-700"}`}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
{item.donorName && (
<p className="text-sm text-gray-500">
<span className="font-medium">Donated by</span> {item.donorName}
</p>
)}
{item.description && (
<p className="text-sm text-gray-600 leading-relaxed">{item.description}</p>
)}
{item.fairMarketValue != null && (
<p className="text-xs text-gray-400">
Fair market value: ${item.fairMarketValue.toLocaleString()}
</p>
)}
</div>
{/* Window countdown */}
{window && window.status === "open" && <WindowTimer window={window} />}
{/* Bid section */}
<div className="card p-5 space-y-3">
<div className="flex justify-between items-center">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">Current bid</p>
<p className="text-3xl font-black text-brand-700 tabular-nums">
{item.currentHighBid != null
? `$${item.currentHighBid.toLocaleString()}`
: `$${item.openingBid.toLocaleString()} start`}
</p>
</div>
{item.currentHighBid != null && (
<p className="text-xs text-gray-400 text-right">
Next bid<br />
<span className="text-sm font-bold text-gray-700">${minNext.toLocaleString()}</span>
</p>
)}
</div>
{bidSuccess && (
<p className="text-emerald-600 text-sm font-semibold text-center">
Bid placed!
</p>
)}
{bidError && (
<p className="text-red-600 text-sm text-center">{bidError}</p>
)}
{!isClosed && (
<button
onClick={() => void handleBid()}
disabled={bidding}
className="w-full btn-primary py-4 text-lg font-black disabled:opacity-40"
>
{bidding ? "Placing bid…" : `Bid $${minNext.toLocaleString()}`}
</button>
)}
{isClosed && item.currentHighBidderId === bidderId && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl px-4 py-3 text-emerald-700 text-sm font-semibold text-center">
🎉 You won this item!
</div>
)}
</div>
{/* Bid history */}
{item.bids.length > 0 && (
<section className="space-y-2">
<p className="section-title">Bid history</p>
<ul className="space-y-1">
{item.bids.map((b) => (
<li
key={b.id}
className={`flex justify-between items-center text-sm rounded-xl px-4 py-2.5 border ${
b.isWinning
? "bg-emerald-50 border-emerald-200"
: "bg-white border-gray-100"
}`}
>
<div className="flex items-center gap-2">
<span className="font-bold text-brand-700">
${Number(b.amount).toLocaleString()}
</span>
{b.isMine && (
<span className="text-xs bg-brand-100 text-brand-700 px-1.5 py-0.5 rounded-full font-semibold">
Mine
</span>
)}
{b.isWinning && (
<span className="text-xs text-emerald-600 font-semibold">High bid</span>
)}
</div>
<span className="text-gray-400 text-xs">
{new Date(b.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</li>
))}
</ul>
</section>
)}
{item.pickupNotes && (
<div className="card p-4 bg-gray-50 border-dashed border-gray-200">
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wide mb-1">Pickup notes</p>
<p className="text-sm text-gray-600">{item.pickupNotes}</p>
</div>
)}
</div>
);
}
+144 -6
View File
@@ -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<BidStatus, { label: string; color: string }> = {
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() {
const bidder = useAuthStore((s) => s.bidder);
const [bids, setBids] = useState<BidWithItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!bidder?.id) return;
api
.get<BidWithItem[]>(`/api/bidders/${bidder.id}/bids`)
.then((all) => {
// Deduplicate: keep only the most recent bid per item
const byItem = new Map<string, BidWithItem>();
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 (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">My Bids</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Bid history not yet implemented
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">📋</span>
<p className="text-gray-400 text-center">Loading your bids</p>
</div>
);
}
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 (
<div className="p-4 space-y-5 animate-fade-in">
<p className="section-title">My Bids</p>
{bids.length === 0 ? (
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
No bids placed yet.{" "}
<Link to="/silent" className="text-brand-700 font-semibold">
Browse the silent auction
</Link>
</div>
) : (
<>
{/* Summary strip */}
<div className="grid grid-cols-4 gap-2">
{[
{ 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 }) => (
<div key={label} className="card p-3 text-center">
<p className={`text-xl font-black tabular-nums ${color}`}>{count}</p>
<p className="text-xs text-gray-400 mt-0.5">{label}</p>
</div>
))}
</div>
{/* Bid list */}
<ul className="space-y-2">
{bids.map((bid) => {
const status = itemBidStatus(bid);
const meta = STATUS_META[status];
return (
<li key={bid.itemId} className="card overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs text-gray-400">Lot {bid.item.lotNumber}</p>
<Link
to={`/items/${bid.itemId}`}
className="font-bold text-gray-900 hover:text-brand-700 transition-colors leading-snug block truncate"
>
{bid.item.title}
</Link>
</div>
<span className={`badge shrink-0 ${meta.color}`}>{meta.label}</span>
</div>
<div className="flex justify-between items-end mt-3">
<div>
<p className="text-xs text-gray-400">Your last bid</p>
<p className="text-lg font-black text-brand-700 tabular-nums">
${Number(bid.amount).toLocaleString()}
</p>
</div>
{bid.item.currentHighBid != null && !bid.isWinning && (
<div className="text-right">
<p className="text-xs text-gray-400">Current high</p>
<p className="text-sm font-bold text-gray-700 tabular-nums">
${bid.item.currentHighBid.toLocaleString()}
</p>
</div>
)}
</div>
{status === "outbid" && (
<Link
to={`/items/${bid.itemId}`}
className="mt-2 block text-center text-xs font-semibold text-brand-700 bg-brand-50 rounded-lg py-1.5"
>
Bid again
</Link>
)}
</div>
</li>
);
})}
</ul>
</>
)}
</div>
);
}
+298 -47
View File
@@ -1,57 +1,75 @@
/**
* 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<string, Date>;
closingSoonWindowIds: Set<string>;
}) {
if (!windowId) return null;
const closeAt = windowCloseMap.get(windowId);
if (!closeAt) return null;
const isSoon = closingSoonWindowIds.has(windowId);
const text = formatCountdown(closeAt);
return (
<span
className={`inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full ${
isSoon
? "bg-red-100 text-red-700 animate-pulse"
: "bg-gray-100 text-gray-500"
}`}
>
{isSoon && "⏰ "}
{text}
</span>
);
}
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 (
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">🏷</span>
<p className="text-gray-400 text-center">Loading silent auction items</p>
</div>
);
}
return (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Silent Auction</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{items.map((item) => {
const isOutbid = outbidItemIds.has(item.id);
// ── Item card ─────────────────────────────────────────────────────────────────
function ItemCard({
item,
isOutbid,
isWatched,
windowCloseMap,
closingSoonWindowIds,
onBid,
onToggleWatch,
}: {
item: AuctionItem;
isOutbid: boolean;
isWatched: boolean;
windowCloseMap: Map<string, Date>;
closingSoonWindowIds: Set<string>;
onBid: (itemId: string, amount: number) => void;
onToggleWatch: (itemId: string) => void;
}) {
const isClosed = item.state === "closed" || item.state === "passed";
const minNext = item.currentHighBid != null
const minNext =
item.currentHighBid != null
? item.currentHighBid + item.bidIncrement
: item.openingBid;
return (
<li
key={item.id}
className={`card overflow-hidden ${isOutbid ? "border-red-300" : ""}`}
>
{/* Outbid banner */}
<li className={`card overflow-hidden animate-fade-in ${isOutbid ? "border-red-300" : ""}`}>
{isOutbid && (
<div className="bg-red-50 text-red-600 text-xs font-bold px-4 py-1.5 border-b border-red-100">
You've been outbid!
@@ -59,33 +77,62 @@ export default function SilentPage({ eventId, auctionId }: Props) {
)}
<div className="p-4 space-y-3">
{/* Header row */}
<div className="flex justify-between items-center">
<p className="text-xs text-gray-400 font-medium">Lot {item.lotNumber}</p>
<span className={`badge ${isClosed ? "bg-gray-100 text-gray-400" : "bg-emerald-100 text-emerald-700"}`}>
<div className="flex items-center gap-2">
<CountdownBadge
windowId={item.silentWindowId}
windowCloseMap={windowCloseMap}
closingSoonWindowIds={closingSoonWindowIds}
/>
<span
className={`badge ${
isClosed
? "bg-gray-100 text-gray-400"
: "bg-emerald-100 text-emerald-700"
}`}
>
{isClosed ? "Closed" : "Open"}
</span>
</div>
</div>
{/* Title + watchlist */}
<div className="flex items-start justify-between gap-2">
<Link
to={`/items/${item.id}`}
className="block font-bold text-gray-900 hover:text-brand-700 transition-colors"
className="font-bold text-gray-900 hover:text-brand-700 transition-colors leading-snug"
>
{item.title}
</Link>
<button
onClick={() => onToggleWatch(item.id)}
aria-label={isWatched ? "Remove from watchlist" : "Add to watchlist"}
className="shrink-0 text-lg leading-none transition-transform active:scale-90"
>
{isWatched ? "⭐" : "☆"}
</button>
</div>
{item.donorName && (
<p className="text-xs text-gray-400">Donated by {item.donorName}</p>
)}
{/* Bid row */}
<div className="flex justify-between items-end pt-1">
<div>
<p className="text-xs text-gray-400 uppercase tracking-wide">Current bid</p>
<p className="text-xl font-black text-brand-700 tabular-nums">
{item.currentHighBid != null
? `$${item.currentHighBid.toLocaleString()}`
: `$${item.openingBid.toLocaleString()}`}
: `$${item.openingBid.toLocaleString()} start`}
</p>
</div>
{!isClosed && (
<button
onClick={() => void placeSilentBid(item.id, minNext)}
onClick={() => onBid(item.id, minNext)}
className="btn-primary text-sm px-4 py-2"
>
Bid ${minNext.toLocaleString()}
@@ -95,8 +142,212 @@ export default function SilentPage({ eventId, auctionId }: Props) {
</div>
</li>
);
})}
</ul>
}
// ── Window section header ─────────────────────────────────────────────────────
function WindowHeader({
window,
itemCount,
windowCloseMap,
closingSoonWindowIds,
}: {
window: SilentAuctionWindow;
itemCount: number;
windowCloseMap: Map<string, Date>;
closingSoonWindowIds: Set<string>;
}) {
const closeAt = windowCloseMap.get(window.id);
const isSoon = closingSoonWindowIds.has(window.id);
return (
<div
className={`flex items-center justify-between px-4 py-2 rounded-xl ${
isSoon ? "bg-red-50 border border-red-200" : "bg-gray-100"
}`}
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
window.status === "open" ? "bg-emerald-500" : "bg-gray-300"
}`} />
<p className="font-semibold text-sm text-gray-800">{window.name}</p>
<span className="text-xs text-gray-400">({itemCount} items)</span>
</div>
<div className="flex items-center gap-2">
{closeAt && window.status === "open" && (
<span className={`text-xs font-semibold ${isSoon ? "text-red-600" : "text-gray-500"}`}>
{isSoon ? "⏰ " : ""}Closes {formatCountdown(closeAt)}
</span>
)}
{window.status === "closed" && (
<span className="text-xs text-gray-400 font-medium">Closed</span>
)}
{window.status === "pending" && (
<span className="text-xs text-gray-400 font-medium">Not open yet</span>
)}
</div>
</div>
);
}
// ── 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<SilentAuctionWindow[]>([]);
const [filter, setFilter] = useState<FilterTab>("all");
// Load items
useEffect(() => {
if (!silentAuctionId) return;
api
.get<AuctionItem[]>(`/api/items?auctionId=${silentAuctionId}`)
.then(setItems)
.catch(console.error);
}, [silentAuctionId, setItems]);
// Load windows and seed close-time map
useEffect(() => {
if (!silentAuctionId) return;
api
.get<SilentAuctionWindow[]>(`/api/auctions/${silentAuctionId}/windows`)
.then((wins) => {
setWindows(wins);
})
.catch(console.error);
}, [silentAuctionId]);
if (ctxLoading) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">🏷</span>
<p className="text-gray-400 text-center">Loading silent auction</p>
</div>
);
}
if (!silentAuctionId) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<span className="text-5xl">🏷</span>
<p className="text-gray-400 text-center">No silent auction found for this event.</p>
</div>
);
}
// 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<string | null, AuctionItem[]>();
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 (
<div className="p-4 space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<p className="section-title">Silent Auction</p>
<span className="text-xs text-gray-400">{items.length} lots</span>
</div>
{/* Filter tabs */}
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
{FILTER_TABS.map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-colors ${
filter === key
? "bg-white text-brand-700 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{label}
</button>
))}
</div>
{visibleItems.length === 0 ? (
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
{filter === "watching"
? "Star an item to add it to your watchlist."
: "No items to show."}
</div>
) : (
<div className="space-y-5">
{windows.length > 0
? windows.map((win) => {
const winItems = windowMap.get(win.id) ?? [];
if (winItems.length === 0 && filter !== "all") return null;
return (
<section key={win.id} className="space-y-2">
<WindowHeader
window={win}
itemCount={winItems.length}
windowCloseMap={windowCloseMap}
closingSoonWindowIds={closingSoonWindowIds}
/>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{winItems.map((item) => (
<ItemCard
key={item.id}
item={item}
isOutbid={outbidItemIds.has(item.id)}
isWatched={watchlist.has(item.id)}
windowCloseMap={windowCloseMap}
closingSoonWindowIds={closingSoonWindowIds}
onBid={(id, amt) => void placeSilentBid(id, amt)}
onToggleWatch={toggleWatchlist}
/>
))}
</ul>
</section>
);
})
: /* No windows defined — show flat list */
(() => {
const unwindowed = windowMap.get(null) ?? [];
return (
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{unwindowed.map((item) => (
<ItemCard
key={item.id}
item={item}
isOutbid={outbidItemIds.has(item.id)}
isWatched={watchlist.has(item.id)}
windowCloseMap={windowCloseMap}
closingSoonWindowIds={closingSoonWindowIds}
onBid={(id, amt) => void placeSilentBid(id, amt)}
onToggleWatch={toggleWatchlist}
/>
))}
</ul>
);
})()}
</div>
)}
</div>
);
}
@@ -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 (
<span className={`font-mono text-sm font-bold ${soon ? "text-red-600 animate-pulse" : "text-gray-700"}`}>
{text}
</span>
);
}
// ── 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<SilentAuctionWindow>(
`/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 (
<div className="card overflow-hidden">
{/* Window header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${win.status === "open" ? "bg-emerald-500" : "bg-gray-300"}`} />
<div>
<p className="font-bold text-gray-900">{win.name}</p>
<p className="text-xs text-gray-400 mt-0.5">
{winItems.length} items · {openCount} open
</p>
</div>
</div>
<div className="flex items-center gap-3">
{win.status === "open" && (
<Countdown closesAt={win.closesAt} />
)}
<span className={`badge ${statusBadge}`}>
{win.status === "pending" ? "Pending" : win.status === "open" ? "Open" : "Closed"}
</span>
{win.status === "pending" && (
<button
disabled={busy}
onClick={() => void setStatus("open")}
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-40"
>
Open
</button>
)}
{win.status === "open" && (
<button
disabled={busy}
onClick={() => void setStatus("closed")}
className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-700 font-semibold hover:bg-gray-200 disabled:opacity-40"
>
Close now
</button>
)}
</div>
</div>
{/* Revenue summary */}
<div className="px-5 py-2 bg-gray-50/50 border-b border-gray-100 flex gap-6">
<div>
<p className="text-xs text-gray-400">High bids total</p>
<p className="text-lg font-black text-brand-700 tabular-nums">
${totalBids.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-400">Items with bids</p>
<p className="text-lg font-black text-gray-800 tabular-nums">
{winItems.filter((i) => i.currentHighBid != null).length} / {winItems.length}
</p>
</div>
{win.softCloseEnabled && (
<div>
<p className="text-xs text-gray-400">Soft close</p>
<p className="text-sm font-bold text-gray-700">+{win.softCloseExtendMinutes}m</p>
</div>
)}
</div>
{/* Item list */}
{winItems.length > 0 ? (
<ul className="divide-y divide-gray-50">
{winItems.map((item) => {
const isClosed = item.state === "closed" || item.state === "passed";
return (
<li
key={item.id}
className={`flex items-center justify-between px-5 py-3 ${isClosed ? "opacity-50" : ""}`}
>
<div className="min-w-0">
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<p className="text-sm font-semibold text-gray-800 truncate">{item.title}</p>
</div>
<div className="text-right shrink-0 ml-4">
{item.currentHighBid != null ? (
<p className="text-sm font-black text-brand-700 tabular-nums">
${item.currentHighBid.toLocaleString()}
</p>
) : (
<p className="text-xs text-gray-400">No bids</p>
)}
{isClosed && (
<p className="text-xs text-gray-400 font-medium">Closed</p>
)}
</div>
</li>
);
})}
</ul>
) : (
<p className="px-5 py-4 text-sm text-gray-400">No items assigned to this window.</p>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function SilentControlPage() {
const [eventId, setEventId] = useState<string | null>(null);
const [auctions, setAuctions] = useState<Auction[]>([]);
const [selectedAuctionId, setSelectedAuctionId] = useState<string | null>(null);
const [windows, setWindows] = useState<SilentAuctionWindow[]>([]);
const [items, setItems] = useState<AuctionItem[]>([]);
// Load the most relevant event (active first, then most recent)
useEffect(() => {
api
.get<Array<{ id: string; status: string }>>("/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<Auction[]>(`/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<SilentAuctionWindow[]>(`/api/auctions/${selectedAuctionId}/windows`),
api.get<AuctionItem[]>(`/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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-brand-700 text-white px-6 py-4 flex items-center justify-between">
<div>
<p className="text-xs text-brand-300 uppercase tracking-widest font-semibold">Staff</p>
<h1 className="text-xl font-black leading-tight">Silent Auction Control</h1>
</div>
{auctions.length > 1 && (
<select
value={selectedAuctionId ?? ""}
onChange={(e) => setSelectedAuctionId(e.target.value)}
className="text-sm bg-brand-800/60 text-white border-brand-600 rounded-lg px-3 py-1.5"
>
{auctions.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
)}
</header>
<div className="max-w-3xl mx-auto px-4 py-6 space-y-4">
{windows.length === 0 && !selectedAuctionId && (
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
No silent auction found for this event.
</div>
)}
{windows.length === 0 && selectedAuctionId && (
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
No windows configured for this auction.
</div>
)}
{windows
.sort((a, b) => new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime())
.map((win) => (
<WindowCard
key={win.id}
win={win}
items={items}
auctionId={selectedAuctionId!}
onWindowUpdated={handleWindowUpdated}
/>
))}
{unassignedItems.length > 0 && (
<div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100">
<p className="font-bold text-gray-900">Unassigned Items</p>
<p className="text-xs text-gray-400 mt-0.5">{unassignedItems.length} items not in any window</p>
</div>
<ul className="divide-y divide-gray-50">
{unassignedItems.map((item) => (
<li key={item.id} className="flex items-center justify-between px-5 py-3">
<div>
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<p className="text-sm font-semibold text-gray-800">{item.title}</p>
</div>
<p className="text-sm font-black text-brand-700 tabular-nums">
{item.currentHighBid != null ? `$${item.currentHighBid.toLocaleString()}` : "—"}
</p>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
+24
View File
@@ -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<ConnectivityState>((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 }),
}));
+1 -2
View File
@@ -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" },
],
},
}),
+45
View File
@@ -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 });
});
+26 -2
View File
@@ -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);
});
+6 -5
View File
@@ -20,7 +20,7 @@ export interface PlaceBidInput {
}
export type BidResult =
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>> }
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>>; 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<BidResult> {
});
// 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<BidResult> {
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 };
});
}
+18
View File
@@ -19,6 +19,7 @@ import type {
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
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<void> {
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<void> {
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(),
});
}
}
+7
View File
@@ -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}`);
@@ -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({
+11
View File
@@ -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