/** * Real-time state hook for the silent auction catalog. * * 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, useRef } from "react"; import { getSocket } from "../lib/socket.js"; import { useOfflineBids } from "./useOfflineBids.js"; import { useAuthStore } from "../store/auth.js"; import { useConnectivityStore } from "../store/connectivity.js"; import type { AuctionItem } from "@storybid/shared"; const WATCHLIST_KEY = "sb_watchlist"; const CLOSING_SOON_MS = 5 * 60 * 1000; function loadWatchlist(): Set { try { const raw = localStorage.getItem(WATCHLIST_KEY); return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); } catch { return new Set(); } } function saveWatchlist(set: Set): void { localStorage.setItem(WATCHLIST_KEY, JSON.stringify([...set])); } export function formatCountdown(closesAt: Date): string { const ms = closesAt.getTime() - Date.now(); if (ms <= 0) return "Closed"; const totalSecs = Math.floor(ms / 1000); const mins = Math.floor(totalSecs / 60); const secs = totalSecs % 60; if (mins >= 60) { const hours = Math.floor(mins / 60); const remMins = mins % 60; return `${hours}h ${remMins}m`; } if (mins >= 5) return `${mins}m`; return `${mins}:${String(secs).padStart(2, "0")}`; } export function useSilentAuction(eventId: string) { const [items, setItems] = useState([]); const [outbidItemIds, setOutbidItemIds] = useState>(new Set()); // windowId → Date close time const [windowCloseMap, setWindowCloseMap] = useState>(new Map()); // windowIds that are closing within 5 minutes const [closingSoonWindowIds, setClosingSoonWindowIds] = useState>(new Set()); const [watchlist, setWatchlist] = useState>(loadWatchlist); // tick forces countdown re-renders every second when any window is open const [tick, setTick] = useState(0); const bidderId = useAuthStore((s) => s.bidder?.id); const socketVersion = useConnectivityStore((s) => s.socketVersion); const { queueBid, getDeviceId } = useOfflineBids(); const clientSeqRef = useRef(0); // Tick every second while windows are open for countdown displays useEffect(() => { if (windowCloseMap.size === 0) return; const id = setInterval(() => { setTick((t) => t + 1); // Recompute closing-soon set const soon = new Set(); for (const [wid, closeAt] of windowCloseMap) { const ms = closeAt.getTime() - Date.now(); if (ms > 0 && ms <= CLOSING_SOON_MS) soon.add(wid); } setClosingSoonWindowIds(soon); }, 1000); return () => clearInterval(id); }, [windowCloseMap]); useEffect(() => { if (!eventId) return; const socket = getSocket(); socket.emit("join_event", eventId); socket.on("silent_bid_accepted", ({ item }) => { setItems((prev) => prev.map((i) => (i.id === item.id ? item : i))); if (item.currentHighBidderId === bidderId) { setOutbidItemIds((prev) => { const next = new Set(prev); next.delete(item.id); return next; }); } }); socket.on("silent_outbid", ({ itemId }) => { setOutbidItemIds((prev) => new Set([...prev, itemId])); }); socket.on("silent_item_closed", ({ itemId }) => { setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, state: "closed" } : i)), ); }); 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, socketVersion]); const placeSilentBid = useCallback( async (itemId: string, amount: number) => { if (!bidderId) return; const socket = getSocket(); const deviceId = getDeviceId(); const seq = ++clientSeqRef.current; if (socket.connected) { socket.emit("place_silent_bid", { itemId, amount, deviceId, clientSeq: seq, clientCreatedAt: new Date().toISOString(), }); } else { await queueBid(itemId, bidderId, amount); } }, [bidderId, getDeviceId, queueBid], ); 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, }; }