Scaffold and Phase 1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/client/package.json ./packages/client/
|
||||
RUN npm ci --workspace=packages/shared --workspace=packages/client
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/client ./packages/client
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
RUN npm run build -w packages/shared
|
||||
RUN npm run build -w packages/client
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=builder /app/packages/client/dist /usr/share/nginx/html
|
||||
COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Storybid Auction</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# PWA – all routes fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache hashed assets aggressively
|
||||
location ~* \.(js|css|woff2|png|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Never cache the service worker or manifest
|
||||
location ~* (service-worker\.js|manifest\.webmanifest)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@storybid/client",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@storybid/shared": "*",
|
||||
"dexie": "^3.2.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"zustand": "^4.5.2",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "*",
|
||||
"vite": "^5.2.13",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConnectivityBanner } from "./components/ConnectivityBanner.js";
|
||||
|
||||
// Bidder-facing pages
|
||||
import HomePage from "./pages/bidder/HomePage.js";
|
||||
import LivePage from "./pages/bidder/LivePage.js";
|
||||
import SilentPage from "./pages/bidder/SilentPage.js";
|
||||
import ItemPage from "./pages/bidder/ItemPage.js";
|
||||
import MyBidsPage from "./pages/bidder/MyBidsPage.js";
|
||||
import CheckoutPage from "./pages/bidder/CheckoutPage.js";
|
||||
import ProfilePage from "./pages/bidder/ProfilePage.js";
|
||||
|
||||
// Auth pages
|
||||
import LoginPage from "./pages/auth/LoginPage.js";
|
||||
import VerifyPage from "./pages/auth/VerifyPage.js";
|
||||
|
||||
// Staff pages
|
||||
import AuctioneerPage from "./pages/staff/AuctioneerPage.js";
|
||||
import SpotterPage from "./pages/staff/SpotterPage.js";
|
||||
import CheckInPage from "./pages/staff/CheckInPage.js";
|
||||
import DisplayBoardPage from "./pages/staff/DisplayBoardPage.js";
|
||||
|
||||
// Admin pages
|
||||
import AdminDashboard from "./pages/admin/DashboardPage.js";
|
||||
import AdminEventsPage from "./pages/admin/EventsPage.js";
|
||||
import AdminItemsPage from "./pages/admin/ItemsPage.js";
|
||||
import AdminBiddersPage from "./pages/admin/BiddersPage.js";
|
||||
import AdminCheckoutPage from "./pages/admin/CheckoutPage.js";
|
||||
import AdminReportingPage from "./pages/admin/ReportingPage.js";
|
||||
import FundANeedPage from "./pages/admin/FundANeedPage.js";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<ConnectivityBanner />
|
||||
<Routes>
|
||||
{/* Auth */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/verify" element={<VerifyPage />} />
|
||||
|
||||
{/* Bidder */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/live" element={<LivePage />} />
|
||||
<Route path="/silent" element={<SilentPage />} />
|
||||
<Route path="/items/:id" element={<ItemPage />} />
|
||||
<Route path="/my-bids" element={<MyBidsPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
|
||||
{/* Staff – optimized single-task views */}
|
||||
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
|
||||
<Route path="/staff/spotter" element={<SpotterPage />} />
|
||||
<Route path="/staff/check-in" element={<CheckInPage />} />
|
||||
<Route path="/display" element={<DisplayBoardPage />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/events" element={<AdminEventsPage />} />
|
||||
<Route path="/admin/items" element={<AdminItemsPage />} />
|
||||
<Route path="/admin/bidders" element={<AdminBiddersPage />} />
|
||||
<Route path="/admin/checkout" element={<AdminCheckoutPage />} />
|
||||
<Route path="/admin/reporting" element={<AdminReportingPage />} />
|
||||
<Route path="/admin/fund-a-need" element={<FundANeedPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useConnectivityStore } from "../store/connectivity.js";
|
||||
|
||||
const labels: Record<string, { text: string; className: string }> = {
|
||||
connected: { text: "Connected", className: "bg-green-500" },
|
||||
local: { text: "Local network – offline-capable", className: "bg-yellow-500" },
|
||||
offline: { text: "Offline – bids will sync when reconnected", className: "bg-red-500" },
|
||||
};
|
||||
|
||||
export function ConnectivityBanner() {
|
||||
const status = useConnectivityStore((s) => s.status);
|
||||
|
||||
if (status === "connected") return null;
|
||||
|
||||
const { text, className } = labels[status]!;
|
||||
|
||||
return (
|
||||
<div className={`${className} text-white text-center text-sm py-1 px-4 font-medium`}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
/* Prevent text-size inflation on mobile */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Thin fetch wrapper – attaches the auth token, handles JSON, and throws
|
||||
* typed errors. All API modules import from here.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("sb_token");
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(path, { ...init, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new ApiError(res.status, body.error ?? res.statusText);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* IndexedDB via Dexie – persists the offline bid outbox and cached event data.
|
||||
*/
|
||||
import Dexie, { type Table } from "dexie";
|
||||
import type { OutboxBid } from "@storybid/shared";
|
||||
|
||||
export class StorybidDB extends Dexie {
|
||||
outbox!: Table<OutboxBid, string>;
|
||||
|
||||
constructor() {
|
||||
super("storybid");
|
||||
|
||||
this.version(1).stores({
|
||||
// localId is the primary key; index itemId for item-scoped queries
|
||||
outbox: "localId, itemId, bidderId, clientCreatedAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new StorybidDB();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
} from "@storybid/shared";
|
||||
|
||||
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
let socket: AppSocket | null = null;
|
||||
|
||||
/**
|
||||
* Returns (or lazily creates) the singleton Socket.io client.
|
||||
*
|
||||
* The connection manager tries the public URL first, then the local-LAN
|
||||
* hostname injected at build-time or from org settings. The server emits
|
||||
* `sync_status_changed` once the transport is established so the UI can
|
||||
* show which path is in use.
|
||||
*/
|
||||
export function getSocket(token?: string): AppSocket {
|
||||
if (socket) return socket;
|
||||
|
||||
socket = io({
|
||||
auth: token ? { token } : undefined,
|
||||
// Reconnect aggressively – events are high-stakes
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionDelayMax: 5000,
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("[socket] connected via", socket?.io.engine.transport.name);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.warn("[socket] disconnected:", reason);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
socket?.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.js";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Admin → Bidders – profiles, paddles, QR codes, CSV import.
|
||||
* TODO: CRUD + bulk import via /api/bidders.
|
||||
*/
|
||||
export default function AdminBiddersPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Bidder Manager</h1>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-2 border rounded-lg text-sm">Import CSV</button>
|
||||
<button className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
||||
+ Add Bidder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Bidder list — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Admin → Checkout – cashier station; find bidder, take payment, print receipt.
|
||||
* TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture.
|
||||
*/
|
||||
export default function AdminCheckoutPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Checkout</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Cashier station — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Admin dashboard – overview of events, recent bids, revenue snapshot.
|
||||
* TODO: fetch org summary from /api/reporting.
|
||||
*/
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<main className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{["Events", "Bidders", "Revenue"].map((label) => (
|
||||
<div key={label} className="border rounded-xl p-5 text-center">
|
||||
<p className="text-gray-500 text-sm">{label}</p>
|
||||
<p className="text-3xl font-bold mt-1">—</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Admin → Events – list, create, edit events.
|
||||
* TODO: CRUD via /api/events.
|
||||
*/
|
||||
export default function AdminEventsPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Events</h1>
|
||||
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
||||
+ New Event
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Events list — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Admin → Fund-a-Need / Paddle Raise – set tiers, open campaign, show live total.
|
||||
* TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events.
|
||||
*/
|
||||
export default function FundANeedPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Fund-a-Need</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Paddle raise setup & live totals — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Admin → Items – manage lots, categories, media, donor info, increments.
|
||||
* TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart).
|
||||
*/
|
||||
export default function AdminItemsPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Item Manager</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Item list & editor — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Admin → Reporting – revenue, sell-through, bidder activity, audit log.
|
||||
* TODO: fetch /api/reporting/events/:id/*.
|
||||
*/
|
||||
export default function AdminReportingPage() {
|
||||
return (
|
||||
<main className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Reporting</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Reports — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Login – email magic link or SMS OTP entry point.
|
||||
* TODO: implement magic-link request form and OTP flow.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<h1 className="text-2xl font-bold text-center">Sign in to bid</h1>
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
Enter your email for a magic link, or your phone number for a one-time code.
|
||||
</p>
|
||||
{/* TODO: LoginForm component */}
|
||||
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm">
|
||||
LoginForm — not yet implemented
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Verify – handles magic-link ?token= callback and OTP confirmation.
|
||||
* TODO: read token from URL, call /api/auth/verify, redirect to /.
|
||||
*/
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-4">
|
||||
<p className="text-gray-500">Verifying…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bidder checkout – shows won lots, total, and Stripe Payment Element.
|
||||
* TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success.
|
||||
*/
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-xl font-bold">Checkout</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Stripe checkout — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Bidder home – event welcome screen, quick nav to Live / Silent / My Bids.
|
||||
* TODO: fetch event details, show upcoming lots, paddle number, QR code.
|
||||
*/
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Welcome to the Auction</h1>
|
||||
<nav className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: "🎙 Live Auction", href: "/live" },
|
||||
{ label: "🔇 Silent Auction", href: "/silent" },
|
||||
{ label: "📋 My Bids", href: "/my-bids" },
|
||||
{ label: "💳 Checkout", href: "/checkout" },
|
||||
].map(({ label, href }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 p-5 text-center font-semibold text-brand-700 hover:bg-brand-50"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Individual silent auction item detail page.
|
||||
* Shows media gallery, description, bid history, and bid form.
|
||||
*
|
||||
* TODO:
|
||||
* - Load item by :id param
|
||||
* - Media carousel (images, video embed, documents)
|
||||
* - Place bid form with offline-outbox fallback via db.outbox
|
||||
*/
|
||||
export default function ItemPage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Item detail — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Live auction bidder view.
|
||||
*
|
||||
* Shows the current lot, current bid, and a single "Bid $X" button for the
|
||||
* auctioneer-called amount. Real-time updates via Socket.io.
|
||||
* Falls back gracefully when no lot is active.
|
||||
*/
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useLiveAuction } from "../../hooks/useLiveAuction.js";
|
||||
import { useOfflineBids } from "../../hooks/useOfflineBids.js";
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
preview: "Up next",
|
||||
active: "Bidding open",
|
||||
going_once: "Going once…",
|
||||
going_twice: "Going twice…",
|
||||
sold: "SOLD",
|
||||
passed: "Passed",
|
||||
};
|
||||
|
||||
export default function LivePage() {
|
||||
// eventId comes from route or a global store; use param or fallback
|
||||
const { eventId = "" } = useParams<{ eventId?: string }>();
|
||||
const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } =
|
||||
useLiveAuction(eventId);
|
||||
const { getDeviceId } = useOfflineBids();
|
||||
|
||||
let clientSeq = 0;
|
||||
|
||||
const handleBid = () => {
|
||||
if (!currentItem || calledAmount == null) return;
|
||||
placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq);
|
||||
};
|
||||
|
||||
const isSold = state === "sold" || state === "passed";
|
||||
const canBid = state === "active" || state === "going_once" || state === "going_twice";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col p-4 gap-6">
|
||||
{/* Status banner */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold">
|
||||
Live Auction
|
||||
</p>
|
||||
{state && (
|
||||
<span
|
||||
className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-bold ${
|
||||
isSold ? "bg-gray-200 text-gray-500" : "bg-brand-100 text-brand-700"
|
||||
}`}
|
||||
>
|
||||
{STATE_LABELS[state] ?? state}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<>
|
||||
{/* Item info */}
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
|
||||
<h1 className="text-2xl font-bold">{currentItem.title}</h1>
|
||||
{currentItem.donorName && (
|
||||
<p className="text-sm text-gray-500">Donated by {currentItem.donorName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current bid */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-400 uppercase tracking-wide">Current bid</p>
|
||||
<p className="text-5xl font-black text-brand-700">
|
||||
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Called amount + bid button */}
|
||||
{calledAmount != null && (
|
||||
<button
|
||||
onClick={handleBid}
|
||||
disabled={!canBid}
|
||||
className="w-full py-6 rounded-2xl bg-brand-600 text-white text-3xl font-black shadow-lg active:scale-95 transition-transform disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Bid ${calledAmount.toLocaleString()}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Recent bids stream */}
|
||||
{recentBids.length > 0 && (
|
||||
<section>
|
||||
<p className="text-xs uppercase tracking-widest text-gray-400 mb-2">Recent bids</p>
|
||||
<ul className="space-y-1">
|
||||
{recentBids.map((b) => (
|
||||
<li key={b.id} className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">{b.createdAt}</span>
|
||||
<span className="font-semibold">${Number(b.amount).toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-gray-400 text-lg">Waiting for the auctioneer to open a lot…</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bidder's personal bid history and watchlist.
|
||||
* TODO: fetch /api/bidders/me/bids, show winning / outbid status per item.
|
||||
*/
|
||||
export default function MyBidsPage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-xl font-bold">My Bids</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Bid history — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bidder profile – paddle number, contact info, digital paddle QR, notifications prefs.
|
||||
* TODO: fetch /api/bidders/me, render paddle QR code.
|
||||
*/
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-xl font-bold">Profile</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Profile & digital paddle — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Silent auction catalog.
|
||||
* Loads items from the API, then keeps them live via Socket.io.
|
||||
* Outbid items are highlighted; offline bids queue to IndexedDB.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSilentAuction } from "../../hooks/useSilentAuction.js";
|
||||
import { api } from "../../lib/api.js";
|
||||
import type { AuctionItem } from "@storybid/shared";
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
auctionId: string;
|
||||
}
|
||||
|
||||
export default function SilentPage({ eventId, auctionId }: Props) {
|
||||
const { items, setItems, outbidItemIds, placeSilentBid } = useSilentAuction(eventId);
|
||||
|
||||
// Initial load from REST catalog
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<AuctionItem[]>(`/api/items?auctionId=${auctionId}`)
|
||||
.then(setItems)
|
||||
.catch(console.error);
|
||||
}, [auctionId, setItems]);
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<main className="p-4">
|
||||
<h1 className="text-xl font-bold mb-4">Silent Auction</h1>
|
||||
<p className="text-gray-400">Loading items…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-xl font-bold">Silent Auction</h1>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{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;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`border rounded-xl overflow-hidden shadow-sm ${
|
||||
isOutbid ? "border-red-400" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* Outbid banner */}
|
||||
{isOutbid && (
|
||||
<div className="bg-red-50 text-red-600 text-xs font-bold px-3 py-1">
|
||||
You've been outbid!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isClosed
|
||||
? "bg-gray-100 text-gray-400"
|
||||
: "bg-green-100 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{isClosed ? "Closed" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link to={`/items/${item.id}`} className="block font-semibold hover:text-brand-600">
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Current bid</p>
|
||||
<p className="text-lg font-bold text-brand-700">
|
||||
{item.currentHighBid != null
|
||||
? `$${item.currentHighBid.toLocaleString()}`
|
||||
: `Starting at $${item.openingBid.toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isClosed && (
|
||||
<button
|
||||
onClick={() => void placeSilentBid(item.id, minNext)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-bold hover:bg-brand-700 active:scale-95 transition-transform"
|
||||
>
|
||||
Bid ${minNext.toLocaleString()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Auctioneer console – optimised for tablet in landscape.
|
||||
* Shows: current lot, current bid, next callable bid, recent bid stream,
|
||||
* and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass.
|
||||
*
|
||||
* TODO:
|
||||
* - Subscribe to all live auction socket events
|
||||
* - Emit auctioneer_* events on button press
|
||||
* - Display large-format current bid and paddle number
|
||||
*/
|
||||
export default function AuctioneerPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-900 text-white p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Auctioneer Console</h1>
|
||||
<div className="border border-dashed border-gray-600 rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||
Live auction controls — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Check-in station – search bidders, scan QR, assign paddle, confirm payment readiness.
|
||||
*
|
||||
* TODO:
|
||||
* - Search /api/bidders?eventId=&q=
|
||||
* - QR scanner via device camera
|
||||
* - POST /api/check-in/:id on confirm
|
||||
* - Show payment-on-file indicator
|
||||
*/
|
||||
export default function CheckInPage() {
|
||||
return (
|
||||
<main className="p-4 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Check-In</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
QR scan & bidder search — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Display board – read-only fullscreen view for projector / TV.
|
||||
* Shows: current item, current bid, bidder paddle, org branding,
|
||||
* and optionally a fundraising thermometer.
|
||||
*
|
||||
* TODO:
|
||||
* - Subscribe to live auction events (read-only socket connection)
|
||||
* - Fullscreen CSS layout with large typography
|
||||
* - Paddle raise thermometer via paddle_raise_update events
|
||||
*/
|
||||
export default function DisplayBoardPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-brand-900 text-white flex flex-col items-center justify-center p-8 space-y-8">
|
||||
<h1 className="text-5xl font-black tracking-tight">Storybid</h1>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p>
|
||||
<p className="text-6xl font-bold">—</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl text-brand-200">Current Bid</p>
|
||||
<p className="text-8xl font-black">$—</p>
|
||||
<p className="text-2xl text-brand-300 mt-2">Paddle —</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Spotter mode – floor volunteer enters bids by paddle number.
|
||||
* Simple: paddle number input + confirm button. Emits auctioneer_accept_bid.
|
||||
*
|
||||
* TODO:
|
||||
* - Show current item and called amount (read-only)
|
||||
* - Large paddle number input with numeric keyboard
|
||||
* - Emit place_live_bid (spotter path) on confirm
|
||||
*/
|
||||
export default function SpotterPage() {
|
||||
return (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Spotter</h1>
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||
Paddle entry — not yet implemented
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import type { Bidder } from "@storybid/shared";
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
bidder: Bidder | null;
|
||||
role: string | null;
|
||||
setAuth: (token: string, bidder: Bidder, role: string) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: localStorage.getItem("sb_token"),
|
||||
bidder: null,
|
||||
role: null,
|
||||
setAuth(token, bidder, role) {
|
||||
localStorage.setItem("sb_token", token);
|
||||
set({ token, bidder, role });
|
||||
},
|
||||
clearAuth() {
|
||||
localStorage.removeItem("sb_token");
|
||||
set({ token: null, bidder: null, role: null });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ConnectivityStatus = "connected" | "local" | "offline";
|
||||
|
||||
interface ConnectivityState {
|
||||
status: ConnectivityStatus;
|
||||
setStatus: (status: ConnectivityStatus) => void;
|
||||
}
|
||||
|
||||
export const useConnectivityStore = create<ConnectivityState>((set) => ({
|
||||
status: navigator.onLine ? "connected" : "offline",
|
||||
setStatus: (status) => set({ status }),
|
||||
}));
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Cache API catalog responses (silent auction items) for offline browsing
|
||||
urlPattern: /\/api\/items/,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-items",
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Always network-first for live bidding endpoints
|
||||
urlPattern: /\/api\/bids/,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: "Storybid Auction",
|
||||
short_name: "Storybid",
|
||||
description: "Live and silent charity auction bidding",
|
||||
theme_color: "#2563eb",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:3001", changeOrigin: true },
|
||||
"/socket.io": { target: "http://localhost:3001", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user