Phase 3 and 4
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user