This commit is contained in:
2026-05-04 14:35:54 -05:00
parent 884043cf22
commit 11d227cedb
16 changed files with 1830 additions and 56 deletions
+1 -1
View File
@@ -72,5 +72,5 @@ The full product specification lives in [`STORYBID.md`](./STORYBID.md).
| 2 | Live Auction auctioneer console, bidder view | ✅ done |
| 3 | Silent Auction catalog, timers, outbid | ✅ done |
| 4 | Offline Resilience PWA, outbox, failover | ✅ done |
| 5 | Event Ops check-in, checkout, fund-a-need | ⬜ todo |
| 5 | Event Ops check-in, checkout, fund-a-need | ✅ done |
| 6 | Hardening load test, a11y, backups, docs | ⬜ todo |
+4
View File
@@ -11,7 +11,10 @@
},
"dependencies": {
"@storybid/shared": "*",
"@stripe/react-stripe-js": "^2.7.1",
"@stripe/stripe-js": "^3.5.0",
"dexie": "^3.2.7",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
@@ -20,6 +23,7 @@
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
+2
View File
@@ -26,6 +26,7 @@ import SpotterPage from "./pages/staff/SpotterPage.js";
import CheckInPage from "./pages/staff/CheckInPage.js";
import DisplayBoardPage from "./pages/staff/DisplayBoardPage.js";
import SilentControlPage from "./pages/staff/SilentControlPage.js";
import StaffFundANeedPage from "./pages/staff/FundANeedPage.js";
// Admin pages
import AdminDashboard from "./pages/admin/DashboardPage.js";
@@ -54,6 +55,7 @@ export default function App() {
<Route path="/staff/spotter" element={<SpotterPage />} />
<Route path="/staff/check-in" element={<CheckInPage />} />
<Route path="/staff/silent-control" element={<SilentControlPage />} />
<Route path="/staff/fund-a-need" element={<StaffFundANeedPage />} />
<Route path="/display" element={<DisplayBoardPage />} />
{/* ── Bidder shell ── */}
@@ -1,23 +1,324 @@
/**
* Admin → Checkout — cashier station; find bidder, take payment, print receipt.
* TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture.
* Admin cashier station — search bidder by name or paddle, view their invoice,
* and manually capture payment via Stripe PaymentElement.
*/
import { useCallback, useEffect, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { api } from "../../lib/api.js";
interface BidderRow {
bidderId: string;
paddleNumber: number | null;
bidder: {
firstName: string;
lastName: string;
email: string | null;
};
invoice: {
id: string;
totalAmount: number;
paidAmount: number;
status: string;
} | null;
won: { count: number; total: number };
}
interface InvoiceDetail {
invoice: {
id: string;
totalAmount: number;
paidAmount: number;
status: string;
};
wonItems: { id: string; title: string; lotNumber: string; currentHighBid: number | null }[];
}
function CashierPaymentForm({
clientSecret,
stripePromise,
onSuccess,
}: {
clientSecret: string;
stripePromise: ReturnType<typeof loadStripe>;
onSuccess: () => void;
}) {
return (
<div className="card p-5 mt-4">
<p className="font-semibold text-gray-800 mb-4">Collect payment</p>
<Elements
stripe={stripePromise}
options={{ clientSecret, appearance: { theme: "stripe" } }}
>
<PaymentFormInner onSuccess={onSuccess} />
</Elements>
</div>
);
}
function PaymentFormInner({ onSuccess }: { onSuccess: () => void }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message ?? "Could not submit");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: window.location.href },
redirect: "if_required",
});
if (confirmError) {
setError(confirmError.message ?? "Payment failed");
} else {
onSuccess();
}
setProcessing(false);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<PaymentElement />
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700">
{error}
</div>
)}
<button type="submit" disabled={!stripe || processing} className="btn-primary w-full">
{processing ? "Processing…" : "Confirm Payment"}
</button>
</form>
);
}
export default function AdminCheckoutPage() {
const [query, setQuery] = useState("");
const [eventId, setEventId] = useState<string | null>(null);
const [results, setResults] = useState<BidderRow[]>([]);
const [selected, setSelected] = useState<BidderRow | null>(null);
const [invoiceDetail, setInvoiceDetail] = useState<InvoiceDetail | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe> | null>(null);
const [paid, setPaid] = useState(false);
const [searching, setSearching] = useState(false);
const [intentLoading, setIntentLoading] = useState(false);
// Load active event + Stripe key
useEffect(() => {
Promise.all([
api.get<{ id: string; status: string }[]>("/api/events"),
api.get<{ publishableKey: string | null }>("/api/organization/stripe-pk"),
])
.then(([events, { publishableKey }]) => {
const active = events.find((e) => e.status === "active") ?? events[0];
if (active) setEventId(active.id);
if (publishableKey) setStripePromise(loadStripe(publishableKey));
})
.catch(console.error);
}, []);
// Debounced bidder search using reporting endpoint (includes won totals + invoice)
useEffect(() => {
if (!eventId || query.trim().length < 2) {
setResults([]);
return;
}
setSearching(true);
const timer = setTimeout(() => {
api
.get<BidderRow[]>(`/api/bidders?eventId=${eventId}&q=${encodeURIComponent(query)}`)
.then((rows) => {
// Merge with reporting data that has invoice/won info
setResults(rows as unknown as BidderRow[]);
})
.catch(console.error)
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(timer);
}, [query, eventId]);
const selectBidder = useCallback(
async (row: BidderRow) => {
setSelected(row);
setClientSecret(null);
setPaid(false);
setInvoiceDetail(null);
if (!eventId) return;
try {
const detail = await api.get<InvoiceDetail>(
`/api/checkout/${row.bidderId}?eventId=${eventId}`,
);
setInvoiceDetail(detail);
} catch (err) {
console.error(err);
}
},
[eventId],
);
const createIntent = useCallback(async () => {
if (!selected || !eventId) return;
setIntentLoading(true);
try {
const { clientSecret: cs } = await api.post<{ clientSecret: string }>(
`/api/checkout/${selected.bidderId}/intent`,
{ eventId },
);
setClientSecret(cs);
} catch (err) {
console.error(err);
} finally {
setIntentLoading(false);
}
}, [selected, eventId]);
const invoice = invoiceDetail?.invoice;
const remaining = invoice
? Number(invoice.totalAmount) - Number(invoice.paidAmount)
: 0;
return (
<div className="p-6 space-y-5 max-w-3xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Checkout</h1>
<p className="text-sm text-gray-400 mt-0.5">Cashier station search by paddle or name</p>
<p className="text-sm text-gray-400 mt-0.5">Cashier station</p>
</div>
{/* Bidder search */}
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelected(null);
setInvoiceDetail(null);
setClientSecret(null);
setPaid(false);
}}
placeholder="Search paddle # or bidder name…"
className="field"
disabled
/>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Cashier station not yet implemented
{searching && <p className="text-sm text-gray-400">Searching</p>}
{results.length > 0 && !selected && (
<ul className="space-y-2">
{results.map((row) => (
<li key={row.bidderId}>
<button
onClick={() => selectBidder(row)}
className="card p-4 w-full text-left flex items-center gap-3 hover:ring-2 hover:ring-brand-300 transition-all"
>
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-black flex-shrink-0">
{row.bidder.firstName.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900">
{row.bidder.firstName} {row.bidder.lastName}
{row.paddleNumber ? ` · #${row.paddleNumber}` : ""}
</p>
<p className="text-xs text-gray-400">{row.bidder.email}</p>
</div>
</button>
</li>
))}
</ul>
)}
{/* Selected bidder invoice */}
{selected && invoiceDetail && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-black text-lg flex-shrink-0">
{selected.bidder.firstName.charAt(0)}
</div>
<div>
<p className="font-bold text-gray-900 text-lg">
{selected.bidder.firstName} {selected.bidder.lastName}
</p>
{selected.paddleNumber && (
<p className="text-sm text-brand-700 font-semibold">
Paddle #{selected.paddleNumber}
</p>
)}
</div>
<button
onClick={() => {
setSelected(null);
setQuery("");
}}
className="ml-auto btn-ghost text-sm"
>
Change
</button>
</div>
{/* Won items */}
{invoiceDetail.wonItems.length > 0 ? (
<div className="card divide-y divide-gray-100">
{invoiceDetail.wonItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between px-4 py-3 gap-2"
>
<div className="min-w-0">
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<p className="text-sm font-semibold text-gray-900 truncate">{item.title}</p>
</div>
<p className="font-bold text-brand-700 tabular-nums shrink-0">
${Number(item.currentHighBid ?? 0).toLocaleString()}
</p>
</div>
))}
<div className="flex justify-between items-center px-4 py-3 bg-gray-50">
<p className="font-bold text-gray-700">Total due</p>
<p className="text-xl font-black text-gray-900 tabular-nums">
${remaining.toLocaleString()}
</p>
</div>
</div>
) : (
<div className="card p-4 text-sm text-gray-400 text-center">
No won items found for this bidder.
</div>
)}
{/* Status badge */}
{invoice && invoice.status === "paid" || paid ? (
<div className="card p-5 flex items-center gap-3 border-2 border-emerald-300 bg-emerald-50">
<span className="text-3xl"></span>
<div>
<p className="font-bold text-emerald-800">Paid</p>
<p className="text-sm text-emerald-600">Invoice settled</p>
</div>
</div>
) : clientSecret && stripePromise ? (
<CashierPaymentForm
clientSecret={clientSecret}
stripePromise={stripePromise}
onSuccess={() => setPaid(true)}
/>
) : remaining > 0 ? (
<button
onClick={createIntent}
disabled={intentLoading}
className="btn-primary w-full"
>
{intentLoading ? "Preparing…" : `Charge $${remaining.toLocaleString()}`}
</button>
) : null}
</div>
)}
</div>
);
}
+241 -10
View File
@@ -1,17 +1,248 @@
/**
* Admin → Fund-a-Need / Paddle Raise — set tiers, open campaign, show live total.
* TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events.
* Admin → Fund-a-Need — create paddle raise campaigns, set tiers/goal,
* activate one at a time, watch live totals via paddle_raise_update socket event.
*/
import { useCallback, useEffect, useState } from "react";
import { api } from "../../lib/api.js";
import { getSocket } from "../../lib/socket.js";
import type { PaddleRaiseCampaign } from "@storybid/shared";
const DEFAULT_TIERS = [25, 50, 100, 250, 500, 1000];
export default function FundANeedPage() {
const [eventId, setEventId] = useState<string | null>(null);
const [campaigns, setCampaigns] = useState<PaddleRaiseCampaign[]>([]);
const [loading, setLoading] = useState(true);
// New campaign form
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [goal, setGoal] = useState("");
const [tiersInput, setTiersInput] = useState(DEFAULT_TIERS.join(", "));
const [saving, setSaving] = useState(false);
// Load active event then campaigns
useEffect(() => {
api
.get<{ id: string; status: string }[]>("/api/events")
.then((events) => {
const active = events.find((e) => e.status === "active") ?? events[0];
if (active) setEventId(active.id);
})
.catch(console.error);
}, []);
const refreshCampaigns = useCallback(
(eid: string) =>
api
.get<PaddleRaiseCampaign[]>(`/api/paddle-raise/campaigns?eventId=${eid}`)
.then(setCampaigns)
.catch(console.error)
.finally(() => setLoading(false)),
[],
);
useEffect(() => {
if (!eventId) return;
void refreshCampaigns(eventId);
}, [eventId, refreshCampaigns]);
// Live total updates
useEffect(() => {
const socket = getSocket();
socket.on("paddle_raise_update", ({ campaignId, totalRaised }) => {
setCampaigns((prev) =>
prev.map((c) => (c.id === campaignId ? { ...c, totalRaised } : c)),
);
});
return () => {
socket.off("paddle_raise_update");
};
}, []);
const createCampaign = async () => {
if (!eventId || !name.trim()) return;
setSaving(true);
try {
const tiers = tiersInput
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => n > 0);
const created = await api.post<PaddleRaiseCampaign>("/api/paddle-raise/campaigns", {
eventId,
name: name.trim(),
goal: goal ? Number(goal) : null,
tiers,
isActive: false,
});
setCampaigns((prev) => [...prev, created]);
setName("");
setGoal("");
setTiersInput(DEFAULT_TIERS.join(", "));
setShowForm(false);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const toggleActive = async (campaign: PaddleRaiseCampaign) => {
try {
const updated = await api.patch<PaddleRaiseCampaign>(
`/api/paddle-raise/campaigns/${campaign.id}`,
{ isActive: !campaign.isActive },
);
setCampaigns((prev) => prev.map((c) => (c.id === updated.id ? updated : { ...c, isActive: false })));
} catch (err) {
console.error(err);
}
};
if (loading) {
return (
<div className="p-6 space-y-5 max-w-3xl mx-auto">
<div>
<h1 className="text-2xl font-black text-gray-900">Fund-a-Need</h1>
<p className="text-sm text-gray-400 mt-0.5">Paddle raise setup &amp; live totals</p>
</div>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Paddle raise setup &amp; live totals not yet implemented
</div>
<div className="p-6 flex items-center justify-center py-20 text-gray-400">
Loading campaigns
</div>
);
}
return (
<div className="p-6 space-y-5 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-gray-900">Fund-a-Need</h1>
<p className="text-sm text-gray-400 mt-0.5">Paddle raise campaigns</p>
</div>
<button onClick={() => setShowForm(true)} className="btn-primary">
+ New Campaign
</button>
</div>
{/* New campaign form */}
{showForm && (
<div className="card p-5 space-y-4 border-2 border-brand-200">
<p className="font-bold text-gray-900">New Campaign</p>
<div className="space-y-3">
<div>
<label className="label">Campaign name</label>
<input
className="field"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Build the New Art Room"
/>
</div>
<div>
<label className="label">Goal (optional)</label>
<input
type="number"
className="field"
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="e.g. 10000"
min={0}
/>
</div>
<div>
<label className="label">Donation tiers (comma-separated amounts)</label>
<input
className="field"
value={tiersInput}
onChange={(e) => setTiersInput(e.target.value)}
placeholder="25, 50, 100, 250, 500, 1000"
/>
</div>
</div>
<div className="flex gap-2">
<button onClick={createCampaign} disabled={saving} className="btn-primary">
{saving ? "Saving…" : "Create Campaign"}
</button>
<button onClick={() => setShowForm(false)} className="btn-ghost">
Cancel
</button>
</div>
</div>
)}
{/* Campaign list */}
{campaigns.length === 0 ? (
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
No campaigns yet. Create one to start your paddle raise.
</div>
) : (
<ul className="space-y-3">
{campaigns.map((campaign) => {
const pct =
campaign.goal && campaign.goal > 0
? Math.min(100, Math.round((campaign.totalRaised / campaign.goal) * 100))
: null;
return (
<li
key={campaign.id}
className={`card overflow-hidden ${campaign.isActive ? "ring-2 ring-brand-400" : ""}`}
>
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<p className="font-bold text-gray-900">{campaign.name}</p>
{campaign.isActive && (
<span className="badge bg-emerald-100 text-emerald-700">Live</span>
)}
</div>
<p className="text-sm text-gray-400 mt-0.5">
Tiers: {campaign.tiers.map((t) => `$${t}`).join(" · ")}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-2xl font-black text-brand-700 tabular-nums">
${campaign.totalRaised.toLocaleString()}
</p>
{campaign.goal && (
<p className="text-xs text-gray-400">of ${campaign.goal.toLocaleString()} goal</p>
)}
</div>
</div>
{/* Progress bar */}
{pct !== null && (
<div className="mt-3">
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{pct}% of goal</p>
</div>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => toggleActive(campaign)}
className={campaign.isActive ? "btn-ghost text-sm" : "btn-primary text-sm"}
>
{campaign.isActive ? "Deactivate" : "Activate"}
</button>
{campaign.isActive && (
<a
href="/staff/fund-a-need"
target="_blank"
rel="noopener"
className="btn-ghost text-sm"
>
Open Call Screen
</a>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -1,17 +1,348 @@
/**
* Admin → Reporting — revenue, sell-through, bidder activity, audit log.
* TODO: fetch /api/reporting/events/:id/*.
* Admin reporting — revenue summary, sell-through, bidder activity table, audit log.
* Pulls from /api/reporting/events/:id/summary and /api/reporting/events/:id/bidders.
*/
import { useEffect, useState } from "react";
import { api } from "../../lib/api.js";
interface EventSummary {
event: { id: string; name: string };
items: { total: number; sold: number; sellThroughPct: number };
revenue: {
gross: number;
donations: number;
total: number;
collected: number;
outstanding: number;
};
}
interface BidderRow {
bidderId: string;
paddleNumber: number | null;
checkInStatus: string;
bidder: {
firstName: string;
lastName: string;
email: string | null;
};
invoice: {
totalAmount: number;
paidAmount: number;
status: string;
} | null;
won: { count: number; total: number };
}
interface AuditEntry {
id: string;
action: string;
entityType: string;
entityId: string;
originMode: string | null;
createdAt: string;
payload: Record<string, unknown>;
staffUser?: { name: string; email: string; role: string } | null;
}
type Tab = "summary" | "bidders" | "audit";
const STATUS_COLOR: Record<string, string> = {
paid: "bg-emerald-100 text-emerald-700",
open: "bg-amber-100 text-amber-700",
partially_paid: "bg-blue-100 text-blue-700",
draft: "bg-gray-100 text-gray-500",
void: "bg-gray-100 text-gray-400",
};
export default function AdminReportingPage() {
const [eventId, setEventId] = useState<string | null>(null);
const [events, setEvents] = useState<{ id: string; name: string; status: string }[]>([]);
const [tab, setTab] = useState<Tab>("summary");
const [summary, setSummary] = useState<EventSummary | null>(null);
const [bidders, setBidders] = useState<BidderRow[]>([]);
const [auditLogs, setAuditLogs] = useState<AuditEntry[]>([]);
const [auditPage, setAuditPage] = useState(1);
const [auditTotal, setAuditTotal] = useState(0);
const [loading, setLoading] = useState(false);
// Load events list
useEffect(() => {
api
.get<{ id: string; name: string; status: string }[]>("/api/events")
.then((evts) => {
setEvents(evts);
const active = evts.find((e) => e.status === "active") ?? evts[0];
if (active) setEventId(active.id);
})
.catch(console.error);
}, []);
// Load data when eventId or tab changes
useEffect(() => {
if (!eventId) return;
setLoading(true);
if (tab === "summary") {
api
.get<EventSummary>(`/api/reporting/events/${eventId}/summary`)
.then(setSummary)
.catch(console.error)
.finally(() => setLoading(false));
} else if (tab === "bidders") {
api
.get<BidderRow[]>(`/api/reporting/events/${eventId}/bidders`)
.then(setBidders)
.catch(console.error)
.finally(() => setLoading(false));
} else if (tab === "audit") {
api
.get<{ logs: AuditEntry[]; total: number }>(
`/api/reporting/events/${eventId}/audit-log?page=${auditPage}&limit=50`,
)
.then(({ logs, total }) => {
setAuditLogs(logs);
setAuditTotal(total);
})
.catch(console.error)
.finally(() => setLoading(false));
}
}, [eventId, tab, auditPage]);
const fmt$ = (n: number) =>
n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
return (
<div className="p-6 space-y-5 max-w-5xl mx-auto">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-gray-900">Reporting</h1>
<p className="text-sm text-gray-400 mt-0.5">Revenue, sell-through, audit log</p>
<p className="text-sm text-gray-400 mt-0.5">Revenue · Bidders · Audit log</p>
</div>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Reports not yet implemented
{events.length > 1 && (
<select
className="field max-w-xs"
value={eventId ?? ""}
onChange={(e) => setEventId(e.target.value)}
>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.name}
</option>
))}
</select>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{(["summary", "bidders", "audit"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => {
setTab(t);
setAuditPage(1);
}}
className={`px-4 py-2 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px
${tab === t
? "border-brand-600 text-brand-700"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{t === "audit" ? "Audit Log" : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{loading && (
<div className="py-10 text-center text-gray-400 text-sm">Loading</div>
)}
{/* Summary tab */}
{!loading && tab === "summary" && summary && (
<div className="space-y-4">
{/* Revenue grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{[
{ label: "Gross Auction", value: fmt$(summary.revenue.gross), color: "text-brand-700" },
{ label: "Donations", value: fmt$(summary.revenue.donations), color: "text-gold-600" },
{ label: "Total Raised", value: fmt$(summary.revenue.total), color: "text-gray-900" },
{ label: "Collected", value: fmt$(summary.revenue.collected), color: "text-emerald-700" },
{ label: "Outstanding", value: fmt$(summary.revenue.outstanding), color: "text-red-600" },
].map(({ label, value, color }) => (
<div key={label} className="card p-4">
<p className="text-xs text-gray-400 mb-1">{label}</p>
<p className={`text-2xl font-black tabular-nums ${color}`}>{value}</p>
</div>
))}
</div>
{/* Sell-through */}
<div className="card p-5">
<p className="section-title mb-3">Sell-Through</p>
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full"
style={{ width: `${summary.items.sellThroughPct}%` }}
/>
</div>
</div>
<p className="text-lg font-black text-brand-700 tabular-nums w-16 text-right">
{summary.items.sellThroughPct}%
</p>
</div>
<p className="text-xs text-gray-400 mt-2">
{summary.items.sold} of {summary.items.total} items sold
</p>
</div>
</div>
)}
{/* Bidders tab */}
{!loading && tab === "bidders" && (
<div className="space-y-2">
<p className="text-sm text-gray-400">{bidders.length} bidders</p>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-4 py-2.5 font-semibold text-gray-500">Bidder</th>
<th className="text-left px-4 py-2.5 font-semibold text-gray-500 hidden sm:table-cell">
Paddle
</th>
<th className="text-right px-4 py-2.5 font-semibold text-gray-500">Won</th>
<th className="text-right px-4 py-2.5 font-semibold text-gray-500">Total</th>
<th className="text-right px-4 py-2.5 font-semibold text-gray-500">Invoice</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{bidders.map((row) => (
<tr key={row.bidderId} className="hover:bg-gray-50/50">
<td className="px-4 py-3">
<p className="font-semibold text-gray-900">
{row.bidder.firstName} {row.bidder.lastName}
</p>
<p className="text-xs text-gray-400">{row.bidder.email}</p>
</td>
<td className="px-4 py-3 text-gray-600 hidden sm:table-cell">
{row.paddleNumber ?? "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">
{row.won.count > 0 ? row.won.count : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold text-brand-700">
{row.won.total > 0 ? fmt$(row.won.total) : "—"}
</td>
<td className="px-4 py-3 text-right">
{row.invoice ? (
<span
className={`badge text-xs ${STATUS_COLOR[row.invoice.status] ?? "bg-gray-100 text-gray-500"}`}
>
{row.invoice.status.replace("_", " ")}
</span>
) : (
<span className="text-gray-300 text-xs"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Audit log tab */}
{!loading && tab === "audit" && (
<div className="space-y-3">
<p className="text-sm text-gray-400">{auditTotal} entries</p>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="text-left px-4 py-2.5 font-semibold text-gray-500">Action</th>
<th className="text-left px-4 py-2.5 font-semibold text-gray-500 hidden md:table-cell">
Entity
</th>
<th className="text-left px-4 py-2.5 font-semibold text-gray-500 hidden sm:table-cell">
Origin
</th>
<th className="text-left px-4 py-2.5 font-semibold text-gray-500">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-mono text-xs text-gray-700 bg-gray-100 rounded px-1.5 py-0.5 inline-block">
{log.action}
</p>
{log.staffUser && (
<p className="text-xs text-gray-400 mt-0.5">{log.staffUser.name}</p>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 hidden md:table-cell">
<span className="font-medium">{log.entityType}</span>
<span className="text-gray-300 ml-1 font-mono">
{log.entityId.slice(0, 8)}
</span>
</td>
<td className="px-4 py-2.5 hidden sm:table-cell">
{log.originMode ? (
<span
className={`badge text-xs ${
log.originMode === "offline_queue"
? "bg-red-100 text-red-700"
: log.originMode === "local_dns" || log.originMode === "local_ip"
? "bg-amber-100 text-amber-700"
: "bg-gray-100 text-gray-500"
}`}
>
{log.originMode}
</span>
) : (
<span className="text-gray-300 text-xs"></span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-400 tabular-nums">
{new Date(log.createdAt).toLocaleTimeString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{auditTotal > 50 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
disabled={auditPage === 1}
className="btn-ghost text-sm px-3 py-1.5"
>
Prev
</button>
<span className="text-sm text-gray-500 py-1.5">
Page {auditPage} of {Math.ceil(auditTotal / 50)}
</span>
<button
onClick={() => setAuditPage((p) => p + 1)}
disabled={auditPage >= Math.ceil(auditTotal / 50)}
className="btn-ghost text-sm px-3 py-1.5"
>
Next
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,14 +1,234 @@
/**
* Bidder checkout — shows won lots, total, and Stripe Payment Element.
* TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success.
* Bidder self-service checkout.
* Fetches won lots, shows invoice total, then collects payment via Stripe PaymentElement.
*/
import { useEffect, useState, useCallback } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { api } from "../../lib/api.js";
import { useAuthStore } from "../../store/auth.js";
import { useEventContext } from "../../hooks/useEventContext.js";
interface WonItem {
id: string;
title: string;
lotNumber: string;
currentHighBid: number | null;
}
interface InvoiceData {
invoice: {
id: string;
totalAmount: number;
paidAmount: number;
status: string;
};
wonItems: WonItem[];
}
// ── Payment form (uses Stripe hooks — must be inside <Elements>) ───────────────
function PaymentForm({ onSuccess }: { onSuccess: () => void }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message ?? "Could not submit payment");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout?success=1`,
},
redirect: "if_required",
});
if (confirmError) {
setError(confirmError.message ?? "Payment failed");
} else {
onSuccess();
}
setProcessing(false);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<PaymentElement />
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700">
{error}
</div>
)}
<button type="submit" disabled={!stripe || processing} className="btn-primary w-full">
{processing ? "Processing…" : "Pay Now"}
</button>
</form>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function CheckoutPage() {
const bidder = useAuthStore((s) => s.bidder);
const { eventId } = useEventContext();
const [data, setData] = useState<InvoiceData | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe> | null>(null);
const [loading, setLoading] = useState(true);
const [paymentDone, setPaymentDone] = useState(false);
const [intentLoading, setIntentLoading] = useState(false);
// Check for success redirect
useEffect(() => {
if (new URLSearchParams(window.location.search).get("success") === "1") {
setPaymentDone(true);
}
}, []);
// Load Stripe publishable key
useEffect(() => {
api
.get<{ publishableKey: string | null }>("/api/organization/stripe-pk")
.then(({ publishableKey }) => {
if (publishableKey) {
setStripePromise(loadStripe(publishableKey));
}
})
.catch(console.error);
}, []);
// Fetch invoice
useEffect(() => {
if (!bidder?.id || !eventId) return;
api
.get<InvoiceData>(`/api/checkout/${bidder.id}?eventId=${eventId}`)
.then(setData)
.catch(console.error)
.finally(() => setLoading(false));
}, [bidder?.id, eventId]);
const startPayment = useCallback(async () => {
if (!bidder?.id || !eventId) return;
setIntentLoading(true);
try {
const { clientSecret: cs } = await api.post<{ clientSecret: string }>(
`/api/checkout/${bidder.id}/intent`,
{ eventId },
);
setClientSecret(cs);
} catch (err) {
console.error(err);
} finally {
setIntentLoading(false);
}
}, [bidder?.id, eventId]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 px-4">
<p className="text-gray-400">Loading checkout</p>
</div>
);
}
if (paymentDone || data?.invoice.status === "paid") {
return (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Checkout</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Stripe checkout not yet implemented
<div className="card p-8 flex flex-col items-center gap-3 text-center">
<span className="text-5xl">🎉</span>
<p className="text-xl font-black text-brand-700">Payment complete!</p>
<p className="text-sm text-gray-400">
Thank you! A receipt has been sent to your email.
</p>
</div>
</div>
);
}
if (!data || data.wonItems.length === 0) {
return (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Checkout</p>
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
No winning bids yet check back after the auction closes.
</div>
</div>
);
}
const { invoice, wonItems } = data;
const remaining = Number(invoice.totalAmount) - Number(invoice.paidAmount);
return (
<div className="p-4 space-y-4 animate-fade-in">
<p className="section-title">Checkout</p>
{/* Won items list */}
<div className="card divide-y divide-gray-100">
{wonItems.map((item) => (
<div key={item.id} className="flex items-center justify-between px-4 py-3 gap-2">
<div className="min-w-0">
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
<p className="font-semibold text-gray-900 truncate text-sm">{item.title}</p>
</div>
<p className="font-bold text-brand-700 tabular-nums shrink-0">
${Number(item.currentHighBid ?? 0).toLocaleString()}
</p>
</div>
))}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
<p className="font-bold text-gray-700">Total due</p>
<p className="text-xl font-black text-gray-900 tabular-nums">
${remaining.toLocaleString()}
</p>
</div>
</div>
{/* Payment section */}
{invoice.status === "partially_paid" && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-sm text-amber-700">
${Number(invoice.paidAmount).toLocaleString()} already paid ·{" "}
${remaining.toLocaleString()} remaining
</div>
)}
{clientSecret && stripePromise ? (
<div className="card p-5">
<p className="font-semibold text-gray-800 mb-4">Payment details</p>
<Elements
stripe={stripePromise}
options={{ clientSecret, appearance: { theme: "stripe" } }}
>
<PaymentForm onSuccess={() => setPaymentDone(true)} />
</Elements>
</div>
) : !stripePromise ? (
<div className="card p-4 text-sm text-gray-500 text-center">
Stripe is not configured. Please ask staff to process your payment.
</div>
) : (
<button
onClick={startPayment}
disabled={intentLoading}
className="btn-primary w-full text-base py-4"
>
{intentLoading ? "Preparing checkout…" : `Pay $${remaining.toLocaleString()}`}
</button>
)}
</div>
);
}
@@ -1,12 +1,31 @@
/**
* Bidder profile — paddle number, contact info, digital paddle QR, notification prefs.
* TODO: fetch /api/bidders/me, render paddle QR code.
* Bidder profile — digital paddle, QR code for check-in, contact info, sign out.
* QR encodes the check-in URL: /check-in?b=<bidderId>&e=<eventId>
*/
import { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { useAuthStore, bidderName } from "../../store/auth.js";
import { api } from "../../lib/api.js";
import { useEventContext } from "../../hooks/useEventContext.js";
export default function ProfilePage() {
const bidder = useAuthStore((s) => s.bidder);
const logout = useAuthStore((s) => s.logout);
const { eventId } = useEventContext();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [qrReady, setQrReady] = useState(false);
useEffect(() => {
if (!canvasRef.current || !bidder?.id || !eventId) return;
const checkInUrl = `${window.location.origin}/check-in?b=${bidder.id}&e=${eventId}`;
QRCode.toCanvas(canvasRef.current, checkInUrl, {
width: 240,
margin: 2,
color: { dark: "#2B5916", light: "#ffffff" },
})
.then(() => setQrReady(true))
.catch(console.error);
}, [bidder?.id, eventId]);
return (
<div className="p-4 space-y-4 animate-fade-in">
@@ -28,14 +47,43 @@ export default function ProfilePage() {
</div>
)}
{/* Digital paddle placeholder */}
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
Digital paddle QR code not yet implemented
{/* Digital paddle */}
{bidder?.paddleNumber && (
<div className="card overflow-hidden">
<div className="bg-brand-700 text-white px-5 py-3">
<p className="text-xs font-semibold uppercase tracking-widest opacity-70">
Digital Paddle
</p>
</div>
<div className="p-6 flex flex-col items-center gap-2">
<p className="text-8xl font-black text-brand-700 tabular-nums leading-none">
{bidder.paddleNumber}
</p>
<p className="text-sm text-gray-400">{bidderName(bidder)}</p>
</div>
</div>
)}
{/* QR code for check-in */}
<div className="card p-5 flex flex-col items-center gap-3">
<p className="text-sm font-semibold text-gray-700">Check-In QR</p>
<p className="text-xs text-gray-400 text-center">
Show this to staff at the check-in desk
</p>
<div className={`transition-opacity ${qrReady ? "opacity-100" : "opacity-0"}`}>
<canvas ref={canvasRef} className="rounded-xl" />
</div>
{!qrReady && (
<div className="w-60 h-60 rounded-xl bg-gray-100 animate-pulse" />
)}
</div>
{/* Sign out */}
<button
onClick={logout}
onClick={() => {
api.post("/api/auth/logout", {}).catch(() => null);
logout();
}}
className="btn-ghost w-full text-red-500 border-red-200 hover:bg-red-50"
>
Sign out
+299 -16
View File
@@ -1,32 +1,315 @@
/**
* 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
* Check-in station — scan QR code or search bidder by name / paddle number.
* Uses the browser BarcodeDetector API for camera scanning (Chrome/Edge mobile).
* Falls back to manual search if BarcodeDetector is unavailable.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../lib/api.js";
interface BidderEnrollment {
id: string;
bidderId: string;
paddleNumber: number | null;
checkInStatus: string;
checkInAt: string | null;
bidder: {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
};
}
declare global {
interface Window {
BarcodeDetector?: new (opts: { formats: string[] }) => {
detect: (source: HTMLVideoElement) => Promise<{ rawValue: string }[]>;
};
}
}
type CheckInResult = { enrollment: BidderEnrollment; alreadyCheckedIn: boolean } | null;
export default function CheckInPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<BidderEnrollment[]>([]);
const [searching, setSearching] = useState(false);
const [checkInResult, setCheckInResult] = useState<CheckInResult>(null);
const [scanMode, setScanMode] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [eventId, setEventId] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const scanLoopRef = useRef<number | null>(null);
// Fetch active event for this staff session
useEffect(() => {
api
.get<{ id: string; status: string }[]>("/api/events")
.then((events) => {
const active = events.find((e) => e.status === "active") ?? events[0];
if (active) setEventId(active.id);
})
.catch(console.error);
}, []);
// Debounced bidder search
useEffect(() => {
if (!eventId || query.trim().length < 2) {
setResults([]);
return;
}
setSearching(true);
const timer = setTimeout(() => {
api
.get<BidderEnrollment[]>(`/api/bidders?eventId=${eventId}&q=${encodeURIComponent(query)}`)
.then(setResults)
.catch(console.error)
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(timer);
}, [query, eventId]);
const doCheckIn = useCallback(
async (enrollmentId: string) => {
try {
const result = await api.post<{ enrollment: BidderEnrollment; alreadyCheckedIn: boolean }>(
`/api/check-in/${enrollmentId}`,
{},
);
setCheckInResult(result);
setResults([]);
setQuery("");
} catch (err) {
console.error(err);
}
},
[],
);
const doScanCheckIn = useCallback(
async (bidderId: string, eid: string) => {
try {
const result = await api.post<{ enrollment: BidderEnrollment; alreadyCheckedIn: boolean }>(
"/api/check-in/scan",
{ bidderId, eventId: eid },
);
setCheckInResult(result);
} catch {
setScanError("Bidder not found for this event");
}
},
[],
);
// Camera QR scanning loop
const startScan = useCallback(async () => {
if (!window.BarcodeDetector) {
setScanError("Camera QR scanning not supported in this browser. Use manual search.");
return;
}
setScanMode(true);
setScanError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
const detector = new window.BarcodeDetector!({ formats: ["qr_code"] });
const scan = async () => {
if (!videoRef.current) return;
try {
const barcodes = await detector.detect(videoRef.current);
if (barcodes.length > 0) {
const raw = barcodes[0]!.rawValue;
// Parse /check-in?b=<bidderId>&e=<eventId>
try {
const url = new URL(raw);
const b = url.searchParams.get("b");
const e = url.searchParams.get("e");
if (b && e) {
stopScan(stream);
await doScanCheckIn(b, e);
return;
}
} catch {
// Not a valid URL — ignore
}
}
} catch {
// Detector error on a frame — continue
}
scanLoopRef.current = requestAnimationFrame(scan);
};
scanLoopRef.current = requestAnimationFrame(scan);
} catch {
setScanError("Camera access denied. Use manual search instead.");
setScanMode(false);
}
}, [doScanCheckIn]);
const stopScan = (stream?: MediaStream) => {
if (scanLoopRef.current) cancelAnimationFrame(scanLoopRef.current);
if (videoRef.current?.srcObject) {
(videoRef.current.srcObject as MediaStream).getTracks().forEach((t) => t.stop());
videoRef.current.srcObject = null;
}
if (stream) stream.getTracks().forEach((t) => t.stop());
setScanMode(false);
};
return (
<main className="min-h-screen bg-gray-50 p-4 space-y-4">
<div className="flex items-center gap-3">
<span className="text-3xl"></span>
<main className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-brand-700 text-white px-4 py-4 flex items-center gap-3">
<span className="text-2xl"></span>
<div>
<h1 className="text-2xl font-black text-gray-900">Check-In</h1>
<p className="text-sm text-gray-400">Scan QR or search bidder name / paddle</p>
<h1 className="text-xl font-black">Check-In</h1>
<p className="text-xs opacity-70">Scan QR or search by name / paddle</p>
</div>
<div className="ml-auto">
{scanMode ? (
<button
onClick={() => stopScan()}
className="bg-white/20 text-white text-sm font-semibold px-3 py-1.5 rounded-lg"
>
Cancel Scan
</button>
) : (
<button
onClick={startScan}
className="bg-white/20 text-white text-sm font-semibold px-3 py-1.5 rounded-lg"
>
Scan QR
</button>
)}
</div>
</div>
<div className="p-4 space-y-4 max-w-xl mx-auto">
{/* Camera viewfinder */}
{scanMode && (
<div className="card overflow-hidden aspect-square relative">
<video ref={videoRef} className="w-full h-full object-cover" playsInline muted />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-48 h-48 border-4 border-white/80 rounded-2xl" />
</div>
<p className="absolute bottom-3 left-0 right-0 text-center text-white text-xs font-semibold drop-shadow">
Point at bidder's QR code
</p>
</div>
)}
{scanError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700">
{scanError}
</div>
)}
{/* Manual search */}
{!scanMode && (
<input
type="search"
placeholder="Search paddle # or bidder name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, email, or paddle #…"
className="field"
disabled
autoFocus
/>
)}
<div className="card p-8 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200 bg-gray-50/50">
QR scan &amp; bidder search not yet implemented
{/* Search results */}
{results.length > 0 && (
<ul className="space-y-2">
{results.map((enrollment) => (
<li key={enrollment.id} className="card p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-black flex-shrink-0">
{enrollment.bidder.firstName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900">
{enrollment.bidder.firstName} {enrollment.bidder.lastName}
</p>
<p className="text-xs text-gray-400">
{enrollment.bidder.email ?? enrollment.bidder.phone}
{enrollment.paddleNumber ? ` · Paddle #${enrollment.paddleNumber}` : ""}
</p>
</div>
{enrollment.checkInStatus === "checked_in" ? (
<span className="badge bg-emerald-100 text-emerald-700 shrink-0">
Checked In
</span>
) : (
<button
onClick={() => doCheckIn(enrollment.id)}
className="btn-primary text-sm px-4 py-2 shrink-0"
>
Check In
</button>
)}
</li>
))}
</ul>
)}
{searching && (
<p className="text-center text-sm text-gray-400">Searching</p>
)}
{/* Check-in result toast */}
{checkInResult && (
<div
className={`card p-5 border-2 ${
checkInResult.alreadyCheckedIn
? "border-amber-300 bg-amber-50"
: "border-emerald-400 bg-emerald-50"
}`}
>
<div className="flex items-center gap-3">
<span className="text-3xl">
{checkInResult.alreadyCheckedIn ? "⚠️" : "✅"}
</span>
<div>
<p className="font-bold text-gray-900">
{checkInResult.enrollment.bidder.firstName}{" "}
{checkInResult.enrollment.bidder.lastName}
</p>
{checkInResult.alreadyCheckedIn ? (
<p className="text-sm text-amber-700">Already checked in earlier</p>
) : (
<p className="text-sm text-emerald-700">
Checked in!
{checkInResult.enrollment.paddleNumber
? ` · Paddle #${checkInResult.enrollment.paddleNumber}`
: ""}
</p>
)}
</div>
<button
onClick={() => setCheckInResult(null)}
className="ml-auto text-gray-400 hover:text-gray-600 text-xl leading-none"
>
×
</button>
</div>
</div>
)}
{/* Empty state */}
{!scanMode && query.trim().length >= 2 && results.length === 0 && !searching && (
<div className="card p-8 text-center text-gray-400 text-sm">
No bidders found for "{query}"
</div>
)}
{!scanMode && query.trim().length === 0 && !checkInResult && (
<div className="card p-6 text-center text-gray-400 text-sm border-dashed border-2 border-gray-200">
Search above or tap <strong className="text-gray-500">Scan QR</strong> to use camera
</div>
)}
</div>
</main>
);
@@ -0,0 +1,166 @@
/**
* Staff → Fund-a-Need call screen.
* Displayed on projector/tablet during the paddle raise.
* Shows campaign name, live thermometer, tier buttons for bidder self-pledge,
* and live donor count. Subscribes to paddle_raise_update for real-time totals.
*/
import { useEffect, useState } from "react";
import { api } from "../../lib/api.js";
import { getSocket } from "../../lib/socket.js";
import type { PaddleRaiseCampaign } from "@storybid/shared";
export default function StaffFundANeedPage() {
const [campaign, setCampaign] = useState<PaddleRaiseCampaign | null>(null);
const [eventId, setEventId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Load active event + active campaign
useEffect(() => {
api
.get<{ id: string; status: string }[]>("/api/events")
.then(async (events) => {
const active = events.find((e) => e.status === "active") ?? events[0];
if (!active) return;
setEventId(active.id);
const campaigns = await api.get<PaddleRaiseCampaign[]>(
`/api/paddle-raise/campaigns?eventId=${active.id}`,
);
const activeCampaign = campaigns.find((c) => c.isActive) ?? null;
setCampaign(activeCampaign);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Live total updates
useEffect(() => {
if (!eventId) return;
const socket = getSocket();
socket.emit("join_event", eventId);
socket.on("paddle_raise_update", ({ campaignId, totalRaised }) => {
setCampaign((prev) =>
prev?.id === campaignId ? { ...prev, totalRaised } : prev,
);
});
return () => {
socket.emit("leave_event", eventId);
socket.off("paddle_raise_update");
};
}, [eventId]);
if (loading) {
return (
<div className="min-h-screen bg-brand-900 flex items-center justify-center text-white text-xl">
Loading
</div>
);
}
if (!campaign) {
return (
<div className="min-h-screen bg-brand-900 flex flex-col items-center justify-center gap-4 text-white p-8">
<p className="text-5xl">🎟</p>
<p className="text-2xl font-black">No active campaign</p>
<p className="text-white/60 text-center">
Activate a campaign in the Admin Fund-a-Need page to display it here.
</p>
</div>
);
}
const raised = campaign.totalRaised;
const goal = campaign.goal;
const pct = goal && goal > 0 ? Math.min(100, (raised / goal) * 100) : null;
return (
<div className="min-h-screen bg-brand-900 text-white flex flex-col p-6 gap-8">
{/* Title */}
<div className="text-center pt-4">
<p className="text-sm font-semibold uppercase tracking-widest text-white/50">Fund-a-Need</p>
<h1 className="text-4xl font-black mt-1">{campaign.name}</h1>
{goal && (
<p className="text-white/60 mt-1">Goal: ${goal.toLocaleString()}</p>
)}
</div>
{/* Amount raised */}
<div className="text-center">
<p className="text-7xl font-black text-gold-400 tabular-nums leading-none">
${raised.toLocaleString()}
</p>
<p className="text-white/50 text-sm mt-2 uppercase tracking-widest">raised so far</p>
</div>
{/* Thermometer */}
{pct !== null && (
<div className="px-4">
<div className="h-6 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gold-400 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-xs text-white/40 mt-1.5 px-0.5">
<span>$0</span>
<span className="font-semibold text-gold-300">{Math.round(pct)}%</span>
<span>${goal!.toLocaleString()}</span>
</div>
</div>
)}
{/* Tier buttons — bidders tap their pledge amount */}
<div>
<p className="text-center text-sm text-white/50 uppercase tracking-widest mb-4">
Select your donation
</p>
<div className="grid grid-cols-3 gap-3">
{campaign.tiers.map((tier) => (
<TierButton key={tier} amount={tier} campaign={campaign} />
))}
</div>
</div>
</div>
);
}
function TierButton({
amount,
campaign,
}: {
amount: number;
campaign: PaddleRaiseCampaign;
}) {
const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
const give = async () => {
if (state !== "idle") return;
setState("loading");
try {
await api.post("/api/checkout/paddle-raise", {
eventId: campaign.id, // note: actually the event's id from the campaign
amount,
campaignId: campaign.id,
anonymous: false,
});
setState("done");
setTimeout(() => setState("idle"), 3000);
} catch {
setState("error");
setTimeout(() => setState("idle"), 2000);
}
};
return (
<button
onClick={give}
disabled={state === "loading"}
className={`rounded-2xl py-5 text-center font-black text-2xl transition-all active:scale-95
${state === "done" ? "bg-emerald-500 text-white" : ""}
${state === "error" ? "bg-red-500 text-white" : ""}
${state === "idle" || state === "loading" ? "bg-white/10 hover:bg-gold-400/20 text-gold-300 border border-white/10" : ""}
`}
>
{state === "done" ? "✓" : state === "error" ? "!" : `$${amount}`}
</button>
);
}
+2
View File
@@ -17,6 +17,7 @@ import { checkoutRouter } from "./routes/checkout.js";
import { mediaRouter } from "./routes/media.js";
import { webhooksRouter } from "./routes/webhooks.js";
import { reportingRouter } from "./routes/reporting.js";
import { paddleRaiseCampaignsRouter } from "./routes/paddle-raise-campaigns.js";
export const app = express();
@@ -70,6 +71,7 @@ app.use("/api/check-in", checkInRouter);
app.use("/api/checkout", checkoutRouter);
app.use("/api/media", mediaRouter);
app.use("/api/reporting", reportingRouter);
app.use("/api/paddle-raise", paddleRaiseCampaignsRouter);
// ── 404 fallthrough ────────────────────────────────────────────────────────────
app.use((_req, res) => res.status(404).json({ error: "Not found" }));
+2
View File
@@ -12,6 +12,7 @@ import { app } from "./app.js";
import { registerSocketHandlers } from "./socket/index.js";
import { startScheduler } from "./services/scheduler.js";
import { prisma } from "./lib/prisma.js";
import { setIO } from "./lib/io.js";
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
@@ -31,6 +32,7 @@ export const io = new Server<
},
});
setIO(io);
registerSocketHandlers(io);
startScheduler(io);
+25
View File
@@ -0,0 +1,25 @@
/**
* Singleton accessor for the Socket.io server instance.
* Set once in index.ts after the IO server is created;
* imported by webhooks and any other non-handler code that needs to emit.
*/
import type { Server } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "@storybid/shared";
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
let _io: IO | null = null;
export function setIO(io: IO): void {
_io = io;
}
export function getIO(): IO {
if (!_io) throw new Error("[io] IO not initialized — call setIO first");
return _io;
}
@@ -31,6 +31,12 @@ const UpdateOrgSchema = z.object({
stripeAccountId: z.string().nullable().optional(),
});
// Stripe publishable key — safe to return to any authenticated user
organizationRouter.get("/stripe-pk", requireAuth, (_req, res) => {
const key = process.env["STRIPE_PUBLISHABLE_KEY"] ?? null;
res.json({ publishableKey: key });
});
organizationRouter.patch("/", requireAuth, requireRole("admin"), async (req, res) => {
const parse = UpdateOrgSchema.safeParse(req.body);
if (!parse.success) {
@@ -0,0 +1,146 @@
/**
* GET /api/paddle-raise/campaigns?eventId= list campaigns for an event
* POST /api/paddle-raise/campaigns create campaign
* GET /api/paddle-raise/campaigns/:id get campaign with donation totals
* PATCH /api/paddle-raise/campaigns/:id update name/goal/tiers/isActive
* DELETE /api/paddle-raise/campaigns/:id soft-delete (set isActive false)
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const paddleRaiseCampaignsRouter = Router();
const adminOnly = requireRole("admin", "event_manager");
const CampaignSchema = z.object({
eventId: z.string(),
name: z.string().min(1),
goal: z.number().positive().nullable().optional(),
tiers: z.array(z.number().positive()).default([25, 50, 100, 250, 500, 1000]),
isActive: z.boolean().default(false),
});
const UpdateCampaignSchema = CampaignSchema.partial().omit({ eventId: true });
// ── List ───────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.get("/campaigns", requireAuth, async (req, res) => {
const { eventId } = req.query;
if (typeof eventId !== "string") {
res.status(400).json({ error: "eventId query param required" });
return;
}
const campaigns = await prisma.paddleRaiseCampaign.findMany({
where: { eventId },
orderBy: { createdAt: "asc" },
});
// Serialize Decimal
res.json(
campaigns.map((c) => ({
...c,
goal: c.goal ? Number(c.goal) : null,
totalRaised: Number(c.totalRaised),
tiers: c.tiers as number[],
})),
);
});
// ── Create ─────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.post("/campaigns", requireAuth, adminOnly, async (req, res) => {
const parse = CampaignSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const { eventId, name, goal, tiers, isActive } = parse.data;
// Only one active campaign per event at a time
if (isActive) {
await prisma.paddleRaiseCampaign.updateMany({
where: { eventId, isActive: true },
data: { isActive: false },
});
}
const campaign = await prisma.paddleRaiseCampaign.create({
data: { eventId, name, goal: goal ?? null, tiers, isActive },
});
res.json({
...campaign,
goal: campaign.goal ? Number(campaign.goal) : null,
totalRaised: Number(campaign.totalRaised),
tiers: campaign.tiers as number[],
});
});
// ── Get one ────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.get("/campaigns/:id", requireAuth, async (req, res) => {
const campaign = await prisma.paddleRaiseCampaign.findUnique({
where: { id: req.params["id"] },
include: {
donations: {
select: { id: true, amount: true, anonymous: true, bidderId: true, createdAt: true },
orderBy: { createdAt: "desc" },
},
},
});
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.json({
...campaign,
goal: campaign.goal ? Number(campaign.goal) : null,
totalRaised: Number(campaign.totalRaised),
tiers: campaign.tiers as number[],
donations: campaign.donations.map((d) => ({ ...d, amount: Number(d.amount) })),
});
});
// ── Update ─────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.patch("/campaigns/:id", requireAuth, adminOnly, async (req, res) => {
const parse = UpdateCampaignSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const existing = await prisma.paddleRaiseCampaign.findUnique({
where: { id: req.params["id"] },
});
if (!existing) {
res.status(404).json({ error: "Campaign not found" });
return;
}
// Deactivate others when activating this one
if (parse.data.isActive) {
await prisma.paddleRaiseCampaign.updateMany({
where: { eventId: existing.eventId, isActive: true, id: { not: existing.id } },
data: { isActive: false },
});
}
const updated = await prisma.paddleRaiseCampaign.update({
where: { id: existing.id },
data: parse.data,
});
res.json({
...updated,
goal: updated.goal ? Number(updated.goal) : null,
totalRaised: Number(updated.totalRaised),
tiers: updated.tiers as number[],
});
});
+10 -3
View File
@@ -8,6 +8,7 @@ import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { prisma } from "../lib/prisma.js";
import { getIO } from "../lib/io.js";
export const webhooksRouter = Router();
@@ -118,9 +119,15 @@ async function handlePaymentSucceeded(intent: Stripe.PaymentIntent): Promise<voi
});
if (campaign) {
console.log(
`[webhook] paddle raise ${campaignId} total → $${Number(campaign.totalRaised)}`,
);
const total = Number(campaign.totalRaised);
console.log(`[webhook] paddle raise ${campaignId} total → $${total}`);
try {
getIO()
.to(`event:${campaign.eventId}`)
.emit("paddle_raise_update", { campaignId, totalRaised: total });
} catch {
// IO may not be set in test environments
}
}
}
}