Phase 3 and 4
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
export default function SilentPage({ eventId, auctionId }: Props) {
|
||||
const { items, setItems, outbidItemIds, placeSilentBid } = useSilentAuction(eventId);
|
||||
// ── 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;
|
||||
|
||||
// Initial load from REST catalog
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<AuctionItem[]>(`/api/items?auctionId=${auctionId}`)
|
||||
.then(setItems)
|
||||
.catch(console.error);
|
||||
}, [auctionId, setItems]);
|
||||
const isSoon = closingSoonWindowIds.has(windowId);
|
||||
const text = formatCountdown(closeAt);
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user