Phase 5
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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>
|
||||
<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 & 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 & live totals — not yet implemented
|
||||
<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>
|
||||
<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>
|
||||
<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 · Bidders · Audit log</p>
|
||||
</div>
|
||||
{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>
|
||||
<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
|
||||
|
||||
{/* 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 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>
|
||||
<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
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search paddle # or bidder name…"
|
||||
className="field"
|
||||
disabled
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 & bidder search — not yet implemented
|
||||
{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"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, email, or paddle #…"
|
||||
className="field"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[],
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user