-
Silent Auction
+
+
+
Silent Auction
+
{items.length} lots
+
-
- {items.map((item) => {
- const isOutbid = outbidItemIds.has(item.id);
- const isClosed = item.state === "closed" || item.state === "passed";
- const minNext = item.currentHighBid != null
- ? item.currentHighBid + item.bidIncrement
- : item.openingBid;
+ {/* Filter tabs */}
+
+ {FILTER_TABS.map(({ key, label }) => (
+
+ ))}
+
- return (
- -
- {/* Outbid banner */}
- {isOutbid && (
-
- ⚡ You've been outbid!
-
- )}
-
-
-
-
Lot {item.lotNumber}
-
- {isClosed ? "Closed" : "Open"}
-
-
-
-
- {item.title}
-
-
-
-
-
Current bid
-
- {item.currentHighBid != null
- ? `$${item.currentHighBid.toLocaleString()}`
- : `$${item.openingBid.toLocaleString()}`}
-
-
-
- {!isClosed && (
-
- )}
-
-
-
- );
- })}
-
+ {visibleItems.length === 0 ? (
+
+ {filter === "watching"
+ ? "Star an item to add it to your watchlist."
+ : "No items to show."}
+
+ ) : (
+
+ {windows.length > 0
+ ? windows.map((win) => {
+ const winItems = windowMap.get(win.id) ?? [];
+ if (winItems.length === 0 && filter !== "all") return null;
+ return (
+
+
+
+ {winItems.map((item) => (
+ void placeSilentBid(id, amt)}
+ onToggleWatch={toggleWatchlist}
+ />
+ ))}
+
+
+ );
+ })
+ : /* No windows defined — show flat list */
+ (() => {
+ const unwindowed = windowMap.get(null) ?? [];
+ return (
+
+ {unwindowed.map((item) => (
+ void placeSilentBid(id, amt)}
+ onToggleWatch={toggleWatchlist}
+ />
+ ))}
+
+ );
+ })()}
+
+ )}
);
}
diff --git a/packages/client/src/pages/staff/SilentControlPage.tsx b/packages/client/src/pages/staff/SilentControlPage.tsx
new file mode 100644
index 0000000..bf138f4
--- /dev/null
+++ b/packages/client/src/pages/staff/SilentControlPage.tsx
@@ -0,0 +1,333 @@
+/**
+ * Staff: Silent Auction Control
+ *
+ * Full-screen staff view for managing silent auction windows during an event.
+ * Shows all windows with live item high bids, and manual open/close controls.
+ */
+import { useEffect, useState, useCallback } from "react";
+import { api } from "../../lib/api.js";
+import { getSocket } from "../../lib/socket.js";
+import { formatCountdown } from "../../hooks/useSilentAuction.js";
+import type { Auction, AuctionItem, SilentAuctionWindow } from "@storybid/shared";
+
+// ── Countdown with tick ───────────────────────────────────────────────────────
+function Countdown({ closesAt }: { closesAt: string }) {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setTick((t) => t + 1), 1000);
+ return () => clearInterval(id);
+ }, []);
+ const text = formatCountdown(new Date(closesAt));
+ const soon = new Date(closesAt).getTime() - Date.now() < 5 * 60 * 1000;
+ return (
+
+ {text}
+
+ );
+}
+
+// ── Window card ───────────────────────────────────────────────────────────────
+function WindowCard({
+ win,
+ items,
+ auctionId,
+ onWindowUpdated,
+}: {
+ win: SilentAuctionWindow;
+ items: AuctionItem[];
+ auctionId: string;
+ onWindowUpdated: (updated: SilentAuctionWindow) => void;
+}) {
+ const [busy, setBusy] = useState(false);
+
+ const setStatus = useCallback(
+ async (status: "open" | "closed") => {
+ setBusy(true);
+ try {
+ const updated = await api.patch
(
+ `/api/auctions/${auctionId}/windows/${win.id}`,
+ { status },
+ );
+ onWindowUpdated(updated);
+ } catch {
+ alert("Failed to update window status.");
+ } finally {
+ setBusy(false);
+ }
+ },
+ [auctionId, win.id, onWindowUpdated],
+ );
+
+ const statusBadge =
+ win.status === "open"
+ ? "bg-emerald-100 text-emerald-700"
+ : win.status === "closed"
+ ? "bg-gray-100 text-gray-400"
+ : "bg-gold-100 text-gold-700";
+
+ const winItems = items.filter((i) => i.silentWindowId === win.id);
+ const openCount = winItems.filter((i) => i.state !== "closed" && i.state !== "passed").length;
+ const totalBids = winItems.reduce((sum, i) => sum + (i.currentHighBid ?? 0), 0);
+
+ return (
+
+ {/* Window header */}
+
+
+
+
+
{win.name}
+
+ {winItems.length} items · {openCount} open
+
+
+
+
+
+ {win.status === "open" && (
+
+ )}
+
+ {win.status === "pending" ? "Pending" : win.status === "open" ? "Open" : "Closed"}
+
+ {win.status === "pending" && (
+
+ )}
+ {win.status === "open" && (
+
+ )}
+
+
+
+ {/* Revenue summary */}
+
+
+
High bids total
+
+ ${totalBids.toLocaleString()}
+
+
+
+
Items with bids
+
+ {winItems.filter((i) => i.currentHighBid != null).length} / {winItems.length}
+
+
+ {win.softCloseEnabled && (
+
+
Soft close
+
+{win.softCloseExtendMinutes}m
+
+ )}
+
+
+ {/* Item list */}
+ {winItems.length > 0 ? (
+
+ {winItems.map((item) => {
+ const isClosed = item.state === "closed" || item.state === "passed";
+ return (
+ -
+
+
Lot {item.lotNumber}
+
{item.title}
+
+
+ {item.currentHighBid != null ? (
+
+ ${item.currentHighBid.toLocaleString()}
+
+ ) : (
+
No bids
+ )}
+ {isClosed && (
+
Closed
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
No items assigned to this window.
+ )}
+
+ );
+}
+
+// ── Main page ─────────────────────────────────────────────────────────────────
+export default function SilentControlPage() {
+ const [eventId, setEventId] = useState(null);
+ const [auctions, setAuctions] = useState([]);
+ const [selectedAuctionId, setSelectedAuctionId] = useState(null);
+ const [windows, setWindows] = useState([]);
+ const [items, setItems] = useState([]);
+
+ // Load the most relevant event (active first, then most recent)
+ useEffect(() => {
+ api
+ .get>("/api/events")
+ .then((events) => {
+ const active = events.find((e) => e.status === "active") ?? events[0];
+ if (active) setEventId(active.id);
+ })
+ .catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ if (!eventId) return;
+ api
+ .get(`/api/auctions?eventId=${eventId}`)
+ .then((all) => {
+ const silent = all.filter((a) => a.type === "silent");
+ setAuctions(silent);
+ if (silent[0]) setSelectedAuctionId(silent[0].id);
+ })
+ .catch(console.error);
+ }, [eventId]);
+
+ useEffect(() => {
+ if (!selectedAuctionId) return;
+ Promise.all([
+ api.get(`/api/auctions/${selectedAuctionId}/windows`),
+ api.get(`/api/items?auctionId=${selectedAuctionId}`),
+ ])
+ .then(([wins, its]) => {
+ setWindows(wins);
+ setItems(its);
+ })
+ .catch(console.error);
+ }, [selectedAuctionId]);
+
+ // Live item updates
+ useEffect(() => {
+ if (!eventId) return;
+ const socket = getSocket();
+ socket.emit("join_event", eventId);
+
+ socket.on("silent_bid_accepted", ({ item }) => {
+ setItems((prev) => prev.map((i) => (i.id === item.id ? item : i)));
+ });
+
+ socket.on("silent_item_closed", ({ itemId }) => {
+ setItems((prev) =>
+ prev.map((i) => (i.id === itemId ? { ...i, state: "closed" } : i)),
+ );
+ });
+
+ socket.on("silent_window_extended", ({ windowId, newClosesAt }) => {
+ setWindows((prev) =>
+ prev.map((w) => (w.id === windowId ? { ...w, closesAt: newClosesAt } : w)),
+ );
+ });
+
+ return () => {
+ socket.emit("leave_event", eventId);
+ socket.off("silent_bid_accepted");
+ socket.off("silent_item_closed");
+ socket.off("silent_window_extended");
+ };
+ }, [eventId]);
+
+ const handleWindowUpdated = useCallback((updated: SilentAuctionWindow) => {
+ setWindows((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
+ // If closed, mark items in that window as closed
+ if (updated.status === "closed") {
+ setItems((prev) =>
+ prev.map((i) =>
+ i.silentWindowId === updated.id && i.state !== "passed"
+ ? { ...i, state: "closed" }
+ : i,
+ ),
+ );
+ }
+ }, []);
+
+ const unassignedItems = items.filter((i) => !i.silentWindowId);
+
+ return (
+
+ {/* Header */}
+
+
+
Staff
+
Silent Auction Control
+
+ {auctions.length > 1 && (
+
+ )}
+
+
+
+ {windows.length === 0 && !selectedAuctionId && (
+
+ No silent auction found for this event.
+
+ )}
+
+ {windows.length === 0 && selectedAuctionId && (
+
+ No windows configured for this auction.
+
+ )}
+
+ {windows
+ .sort((a, b) => new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime())
+ .map((win) => (
+
+ ))}
+
+ {unassignedItems.length > 0 && (
+
+
+
Unassigned Items
+
{unassignedItems.length} items not in any window
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/client/src/store/connectivity.ts b/packages/client/src/store/connectivity.ts
index 9ea2e71..658e627 100644
--- a/packages/client/src/store/connectivity.ts
+++ b/packages/client/src/store/connectivity.ts
@@ -4,10 +4,34 @@ export type ConnectivityStatus = "connected" | "local" | "offline";
interface ConnectivityState {
status: ConnectivityStatus;
+ // Incremented each time the socket needs to reconnect to a new URL
+ socketVersion: number;
+ // Local LAN server URL (e.g. "http://auction.event.lan:3001")
+ localUrl: string | null;
+ // How many bids are queued in the offline outbox
+ outboxCount: number;
+
setStatus: (status: ConnectivityStatus) => void;
+ incrementSocketVersion: () => void;
+ setLocalUrl: (url: string | null) => void;
+ setOutboxCount: (count: number) => void;
}
export const useConnectivityStore = create((set) => ({
status: navigator.onLine ? "connected" : "offline",
+ socketVersion: 0,
+ localUrl: localStorage.getItem("sb_local_url"),
+ outboxCount: 0,
+
setStatus: (status) => set({ status }),
+ incrementSocketVersion: () => set((s) => ({ socketVersion: s.socketVersion + 1 })),
+ setLocalUrl: (url) => {
+ if (url) {
+ localStorage.setItem("sb_local_url", url);
+ } else {
+ localStorage.removeItem("sb_local_url");
+ }
+ set({ localUrl: url });
+ },
+ setOutboxCount: (outboxCount) => set({ outboxCount }),
}));
diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts
index 179bb71..8a01787 100644
--- a/packages/client/vite.config.ts
+++ b/packages/client/vite.config.ts
@@ -36,8 +36,7 @@ export default defineConfig({
orientation: "portrait",
start_url: "/",
icons: [
- { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
- { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
+ { src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" },
],
},
}),
diff --git a/packages/server/src/routes/auctions.ts b/packages/server/src/routes/auctions.ts
index 19b92e8..45c86e5 100644
--- a/packages/server/src/routes/auctions.ts
+++ b/packages/server/src/routes/auctions.ts
@@ -142,3 +142,48 @@ auctionsRouter.post("/:id/windows", requireAuth, STAFF_WRITE, async (req, res) =
});
res.status(201).json(window);
});
+
+const UpdateWindowSchema = z.object({
+ name: z.string().min(1).optional(),
+ opensAt: z.string().datetime().optional(),
+ closesAt: z.string().datetime().optional(),
+ softCloseEnabled: z.boolean().optional(),
+ softCloseExtendMinutes: z.number().int().min(1).max(60).optional(),
+ status: z.enum(["pending", "open", "closed"]).optional(),
+});
+
+auctionsRouter.patch("/:id/windows/:windowId", requireAuth, AUCTIONEER, async (req, res) => {
+ const parse = UpdateWindowSchema.safeParse(req.body);
+ if (!parse.success) {
+ res.status(400).json({ error: parse.error.flatten() });
+ return;
+ }
+ const window = await prisma.silentAuctionWindow.updateMany({
+ where: { id: req.params["windowId"], auctionId: req.params["id"] },
+ data: parse.data,
+ });
+ if (window.count === 0) {
+ res.status(404).json({ error: "Window not found" });
+ return;
+ }
+ const updated = await prisma.silentAuctionWindow.findUnique({
+ where: { id: req.params["windowId"] },
+ });
+ res.json(updated);
+});
+
+auctionsRouter.delete("/:id/windows/:windowId", requireAuth, STAFF_WRITE, async (req, res) => {
+ const window = await prisma.silentAuctionWindow.findFirst({
+ where: { id: req.params["windowId"], auctionId: req.params["id"] },
+ });
+ if (!window) {
+ res.status(404).json({ error: "Window not found" });
+ return;
+ }
+ if (window.status !== "pending") {
+ res.status(409).json({ error: "Cannot delete a window that has been opened" });
+ return;
+ }
+ await prisma.silentAuctionWindow.delete({ where: { id: window.id } });
+ res.json({ ok: true });
+});
diff --git a/packages/server/src/routes/bidders.ts b/packages/server/src/routes/bidders.ts
index 1ec57ef..c016dc7 100644
--- a/packages/server/src/routes/bidders.ts
+++ b/packages/server/src/routes/bidders.ts
@@ -259,7 +259,31 @@ biddersRouter.get("/:id/bids", requireAuth, async (req, res) => {
const bids = await prisma.bid.findMany({
where: { bidderId: req.params["id"] },
orderBy: { createdAt: "desc" },
- include: { item: { select: { title: true, lotNumber: true, state: true } } },
+ include: {
+ item: {
+ select: {
+ title: true,
+ lotNumber: true,
+ state: true,
+ currentHighBid: true,
+ currentHighBidderId: true,
+ },
+ },
+ },
});
- res.json(bids);
+
+ // Serialize Decimal fields
+ const serialized = bids.map((b) => ({
+ ...b,
+ amount: Number(b.amount),
+ clientCreatedAt: b.clientCreatedAt.toISOString(),
+ serverReceivedAt: b.serverReceivedAt.toISOString(),
+ createdAt: b.createdAt.toISOString(),
+ item: {
+ ...b.item,
+ currentHighBid: b.item.currentHighBid ? Number(b.item.currentHighBid) : null,
+ },
+ }));
+
+ res.json(serialized);
});
diff --git a/packages/server/src/services/bid-engine.ts b/packages/server/src/services/bid-engine.ts
index f84b067..d7df061 100644
--- a/packages/server/src/services/bid-engine.ts
+++ b/packages/server/src/services/bid-engine.ts
@@ -20,7 +20,7 @@ export interface PlaceBidInput {
}
export type BidResult =
- | { ok: true; bid: Awaited>; item: Awaited> }
+ | { ok: true; bid: Awaited>; item: Awaited>; windowExtendedTo?: string }
| { ok: false; error: string; code: "ITEM_NOT_FOUND" | "WINDOW_CLOSED" | "ITEM_STATE" | "AMOUNT_TOO_LOW" | "DUPLICATE" };
/**
@@ -121,6 +121,7 @@ export async function placeBid(input: PlaceBidInput): Promise {
});
// 8. Soft-close extension for silent auction
+ let windowExtendedTo: string | undefined;
if (
auction.type === "silent" &&
updatedItem.softCloseEnabled &&
@@ -133,16 +134,16 @@ export async function placeBid(input: PlaceBidInput): Promise {
const msRemaining = window.closesAt.getTime() - Date.now();
const extendThresholdMs = updatedItem.softCloseExtendMinutes * 60 * 1000;
if (msRemaining < extendThresholdMs) {
+ const newClosesAt = new Date(Date.now() + extendThresholdMs);
await tx.silentAuctionWindow.update({
where: { id: window.id },
- data: {
- closesAt: new Date(Date.now() + extendThresholdMs),
- },
+ data: { closesAt: newClosesAt },
});
+ windowExtendedTo = newClosesAt.toISOString();
}
}
}
- return { ok: true, bid, item: updatedItem };
+ return { ok: true, bid, item: updatedItem, windowExtendedTo };
});
}
diff --git a/packages/server/src/services/scheduler.ts b/packages/server/src/services/scheduler.ts
index 81b8c77..7d452be 100644
--- a/packages/server/src/services/scheduler.ts
+++ b/packages/server/src/services/scheduler.ts
@@ -19,6 +19,7 @@ import type {
type IO = Server;
const POLL_INTERVAL_MS = 10_000;
+const CLOSING_WARN_MS = 5 * 60 * 1000; // warn 5 minutes before close
export function startScheduler(io: IO): void {
console.log("[scheduler] starting silent auction window poller");
@@ -107,6 +108,7 @@ async function closeExpiredWindows(io: IO): Promise {
data: { status: "open" },
});
+ // Tell clients the close time so they can start their countdowns
io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
windowId: window.id,
closesAt: window.closesAt.toISOString(),
@@ -114,4 +116,20 @@ async function closeExpiredWindows(io: IO): Promise {
console.log(`[scheduler] opened window ${window.id} (${window.name})`);
}
+
+ // Emit a closing-soon warning for open windows within the 5-minute threshold
+ const closingSoonWindows = await prisma.silentAuctionWindow.findMany({
+ where: {
+ status: "open",
+ closesAt: { gt: now, lte: new Date(now.getTime() + CLOSING_WARN_MS) },
+ },
+ include: { auction: { select: { eventId: true } } },
+ });
+
+ for (const window of closingSoonWindows) {
+ io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
+ windowId: window.id,
+ closesAt: window.closesAt.toISOString(),
+ });
+ }
}
diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts
index 156f323..e79fd6c 100644
--- a/packages/server/src/socket/index.ts
+++ b/packages/server/src/socket/index.ts
@@ -45,6 +45,13 @@ export function registerSocketHandlers(io: IO): void {
void socket.join(`bidder:${socket.data.bidderId}`);
}
+ // Tell the client which connectivity path is active.
+ // The x-origin-mode handshake header is set by the client's connection manager.
+ const originHint = socket.handshake.headers["x-origin-mode"] as string | undefined;
+ const syncStatus: "connected" | "local" | "offline" =
+ originHint === "local_dns" || originHint === "local_ip" ? "local" : "connected";
+ socket.emit("sync_status_changed", { status: syncStatus });
+
// Room join/leave for event-scoped broadcasts
socket.on("join_event", (eventId) => {
void socket.join(`event:${eventId}`);
diff --git a/packages/server/src/socket/silent-auction.ts b/packages/server/src/socket/silent-auction.ts
index b8463bb..e2f5257 100644
--- a/packages/server/src/socket/silent-auction.ts
+++ b/packages/server/src/socket/silent-auction.ts
@@ -70,6 +70,14 @@ export function registerSilentAuctionHandlers(io: IO, socket: Sock): void {
item: serializedItem,
});
+ // If a soft-close extension happened, tell all clients the new close time
+ if (result.windowExtendedTo && serializedItem.silentWindowId) {
+ io.to(`event:${item.auction.eventId}`).emit("silent_window_extended", {
+ windowId: serializedItem.silentWindowId,
+ newClosesAt: result.windowExtendedTo,
+ });
+ }
+
// Notify the previously winning bidder that they've been outbid.
// We find the second-highest bid for this item.
const previousBid = await prisma.bid.findFirst({
diff --git a/packages/shared/src/types/bid.ts b/packages/shared/src/types/bid.ts
index 3abd077..6062f70 100644
--- a/packages/shared/src/types/bid.ts
+++ b/packages/shared/src/types/bid.ts
@@ -24,6 +24,17 @@ export interface Bid {
createdAt: string;
}
+// Bid enriched with item summary — returned by GET /api/bidders/:id/bids
+export interface BidWithItem extends Bid {
+ item: {
+ title: string;
+ lotNumber: string;
+ state: string;
+ currentHighBid: number | null;
+ currentHighBidderId: string | null;
+ };
+}
+
// Outbox entry stored in IndexedDB before network sync
export interface OutboxBid {
localId: string; // UUID generated client-side