Scaffold and Phase 1
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Real-time state hook for the live auction bidder view.
|
||||
* Subscribes to item_activated, next_live_bid, live_bid_accepted,
|
||||
* item_state_changed, item_sold.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { getSocket } from "../lib/socket.js";
|
||||
import type { AuctionItem, Bid, ItemState } from "@storybid/shared";
|
||||
|
||||
export interface LiveAuctionState {
|
||||
currentItem: AuctionItem | null;
|
||||
currentBid: number | null;
|
||||
calledAmount: number | null;
|
||||
state: ItemState | null;
|
||||
recentBids: Bid[];
|
||||
}
|
||||
|
||||
export function useLiveAuction(eventId: string) {
|
||||
const [state, setState] = useState<LiveAuctionState>({
|
||||
currentItem: null,
|
||||
currentBid: null,
|
||||
calledAmount: null,
|
||||
state: null,
|
||||
recentBids: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
socket.emit("join_event", eventId);
|
||||
|
||||
socket.on("item_activated", ({ item }) => {
|
||||
setState({
|
||||
currentItem: item,
|
||||
currentBid: item.currentHighBid,
|
||||
calledAmount: item.openingBid,
|
||||
state: item.state,
|
||||
recentBids: [],
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("next_live_bid", ({ amount }) => {
|
||||
setState((prev) => ({ ...prev, calledAmount: amount }));
|
||||
});
|
||||
|
||||
socket.on("live_bid_accepted", ({ bid, item }) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentBid: item.currentHighBid,
|
||||
state: item.state,
|
||||
currentItem: item,
|
||||
recentBids: [bid, ...prev.recentBids].slice(0, 10),
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on("item_state_changed", ({ itemId, state: newState }) => {
|
||||
setState((prev) => {
|
||||
if (prev.currentItem?.id !== itemId) return prev;
|
||||
return { ...prev, state: newState };
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("item_sold", ({ itemId, amount }) => {
|
||||
setState((prev) => {
|
||||
if (prev.currentItem?.id !== itemId) return prev;
|
||||
return { ...prev, currentBid: amount, state: "sold" };
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.emit("leave_event", eventId);
|
||||
socket.off("item_activated");
|
||||
socket.off("next_live_bid");
|
||||
socket.off("live_bid_accepted");
|
||||
socket.off("item_state_changed");
|
||||
socket.off("item_sold");
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
const placeBid = (itemId: string, amount: number, deviceId: string, clientSeq: number) => {
|
||||
const socket = getSocket();
|
||||
socket.emit("place_live_bid", {
|
||||
itemId,
|
||||
amount,
|
||||
deviceId,
|
||||
clientSeq,
|
||||
clientCreatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return { ...state, placeBid };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { db } from "../lib/db.js";
|
||||
import { getSocket } from "../lib/socket.js";
|
||||
import { useConnectivityStore } from "../store/connectivity.js";
|
||||
import type { OutboxBid } from "@storybid/shared";
|
||||
|
||||
const DEVICE_ID_KEY = "sb_device_id";
|
||||
|
||||
function getDeviceId(): string {
|
||||
let id = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (!id) {
|
||||
id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
let clientSeq = 0;
|
||||
|
||||
export function useOfflineBids() {
|
||||
const setStatus = useConnectivityStore((s) => s.setStatus);
|
||||
|
||||
const syncOutbox = useCallback(async () => {
|
||||
const pending = await db.outbox.toArray();
|
||||
if (!pending.length) return;
|
||||
|
||||
const socket = getSocket();
|
||||
if (!socket.connected) return;
|
||||
|
||||
socket.emit(
|
||||
"sync_outbox",
|
||||
pending.map((b) => ({
|
||||
localId: b.localId,
|
||||
itemId: b.itemId,
|
||||
amount: b.amount,
|
||||
deviceId: b.deviceId,
|
||||
clientSeq: b.clientSeq,
|
||||
clientCreatedAt: b.clientCreatedAt,
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
// Listen for sync results and clear acknowledged entries
|
||||
const onSyncResult = (result: { localId: string; accepted: boolean }) => {
|
||||
if (result.accepted) {
|
||||
void db.outbox.delete(result.localId);
|
||||
}
|
||||
};
|
||||
|
||||
const onReconnect = () => {
|
||||
setStatus("connected");
|
||||
void syncOutbox();
|
||||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
setStatus(navigator.onLine ? "local" : "offline");
|
||||
};
|
||||
|
||||
const onOnline = () => {
|
||||
setStatus("connected");
|
||||
void syncOutbox();
|
||||
};
|
||||
|
||||
const onOffline = () => setStatus("offline");
|
||||
|
||||
socket.on("bid_sync_result", onSyncResult);
|
||||
socket.on("connect", onReconnect);
|
||||
socket.on("disconnect", onDisconnect);
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("offline", onOffline);
|
||||
|
||||
return () => {
|
||||
socket.off("bid_sync_result", onSyncResult);
|
||||
socket.off("connect", onReconnect);
|
||||
socket.off("disconnect", onDisconnect);
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("offline", onOffline);
|
||||
};
|
||||
}, [setStatus, syncOutbox]);
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
localId: uuidv4(),
|
||||
itemId,
|
||||
bidderId,
|
||||
amount,
|
||||
clientCreatedAt: new Date().toISOString(),
|
||||
deviceId: getDeviceId(),
|
||||
clientSeq: ++clientSeq,
|
||||
attempts: 0,
|
||||
lastAttemptAt: null,
|
||||
};
|
||||
await db.outbox.add(entry);
|
||||
return entry.localId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { queueBid, syncOutbox, getDeviceId };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getSocket } from "../lib/socket.js";
|
||||
import { useOfflineBids } from "./useOfflineBids.js";
|
||||
import { useAuthStore } from "../store/auth.js";
|
||||
import type { AuctionItem } from "@storybid/shared";
|
||||
|
||||
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();
|
||||
|
||||
let clientSeq = 0;
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
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)),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.emit("leave_event", eventId);
|
||||
socket.off("silent_bid_accepted");
|
||||
socket.off("silent_outbid");
|
||||
socket.off("silent_item_closed");
|
||||
};
|
||||
}, [eventId, bidderId]);
|
||||
|
||||
const placeSilentBid = useCallback(
|
||||
async (itemId: string, amount: number) => {
|
||||
if (!bidderId) return;
|
||||
const socket = getSocket();
|
||||
const deviceId = getDeviceId();
|
||||
const seq = ++clientSeq;
|
||||
|
||||
if (socket.connected) {
|
||||
socket.emit("place_silent_bid", {
|
||||
itemId,
|
||||
amount,
|
||||
deviceId,
|
||||
clientSeq: seq,
|
||||
clientCreatedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Offline – write to IndexedDB outbox
|
||||
await queueBid(itemId, bidderId, amount);
|
||||
}
|
||||
},
|
||||
[bidderId, getDeviceId, queueBid],
|
||||
);
|
||||
|
||||
return { items, setItems, outbidItemIds, placeSilentBid };
|
||||
}
|
||||
Reference in New Issue
Block a user