184 lines
5.5 KiB
TypeScript
184 lines
5.5 KiB
TypeScript
/**
|
|
* 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<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());
|
|
// 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);
|
|
|
|
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)));
|
|
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,
|
|
};
|
|
}
|