diff --git a/README.md b/README.md index 8de21a5..9f64980 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/packages/client/package.json b/packages/client/package.json index c85f4dd..c4fadeb 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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", diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index e41b716..4da1e3f 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> {/* ── Bidder shell ── */} diff --git a/packages/client/src/pages/admin/CheckoutPage.tsx b/packages/client/src/pages/admin/CheckoutPage.tsx index fba8aab..3a3c75f 100644 --- a/packages/client/src/pages/admin/CheckoutPage.tsx +++ b/packages/client/src/pages/admin/CheckoutPage.tsx @@ -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; + onSuccess: () => void; +}) { + return ( +
+

Collect payment

+ + + +
+ ); +} + +function PaymentFormInner({ onSuccess }: { onSuccess: () => void }) { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(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 ( +
+ + {error && ( +
+ {error} +
+ )} + + + ); +} + export default function AdminCheckoutPage() { + const [query, setQuery] = useState(""); + const [eventId, setEventId] = useState(null); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState(null); + const [invoiceDetail, setInvoiceDetail] = useState(null); + const [clientSecret, setClientSecret] = useState(null); + const [stripePromise, setStripePromise] = useState | 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(`/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( + `/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 (

Checkout

-

Cashier station — search by paddle or name

+

Cashier station

+ + {/* Bidder search */} { + setQuery(e.target.value); + setSelected(null); + setInvoiceDetail(null); + setClientSecret(null); + setPaid(false); + }} placeholder="Search paddle # or bidder name…" className="field" - disabled /> -
- Cashier station — not yet implemented -
+ + {searching &&

Searching…

} + + {results.length > 0 && !selected && ( +
    + {results.map((row) => ( +
  • + +
  • + ))} +
+ )} + + {/* Selected bidder invoice */} + {selected && invoiceDetail && ( +
+
+
+ {selected.bidder.firstName.charAt(0)} +
+
+

+ {selected.bidder.firstName} {selected.bidder.lastName} +

+ {selected.paddleNumber && ( +

+ Paddle #{selected.paddleNumber} +

+ )} +
+ +
+ + {/* Won items */} + {invoiceDetail.wonItems.length > 0 ? ( +
+ {invoiceDetail.wonItems.map((item) => ( +
+
+

Lot {item.lotNumber}

+

{item.title}

+
+

+ ${Number(item.currentHighBid ?? 0).toLocaleString()} +

+
+ ))} +
+

Total due

+

+ ${remaining.toLocaleString()} +

+
+
+ ) : ( +
+ No won items found for this bidder. +
+ )} + + {/* Status badge */} + {invoice && invoice.status === "paid" || paid ? ( +
+ +
+

Paid

+

Invoice settled

+
+
+ ) : clientSecret && stripePromise ? ( + setPaid(true)} + /> + ) : remaining > 0 ? ( + + ) : null} +
+ )}
); } diff --git a/packages/client/src/pages/admin/FundANeedPage.tsx b/packages/client/src/pages/admin/FundANeedPage.tsx index 9c53405..c92db11 100644 --- a/packages/client/src/pages/admin/FundANeedPage.tsx +++ b/packages/client/src/pages/admin/FundANeedPage.tsx @@ -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(null); + const [campaigns, setCampaigns] = useState([]); + 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(`/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("/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( + `/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 ( +
+ Loading campaigns… +
+ ); + } + return (
-
-

Fund-a-Need

-

Paddle raise setup & live totals

-
-
- Paddle raise setup & live totals — not yet implemented +
+
+

Fund-a-Need

+

Paddle raise campaigns

+
+
+ + {/* New campaign form */} + {showForm && ( +
+

New Campaign

+
+
+ + setName(e.target.value)} + placeholder="e.g. Build the New Art Room" + /> +
+
+ + setGoal(e.target.value)} + placeholder="e.g. 10000" + min={0} + /> +
+
+ + setTiersInput(e.target.value)} + placeholder="25, 50, 100, 250, 500, 1000" + /> +
+
+
+ + +
+
+ )} + + {/* Campaign list */} + {campaigns.length === 0 ? ( +
+ No campaigns yet. Create one to start your paddle raise. +
+ ) : ( +
    + {campaigns.map((campaign) => { + const pct = + campaign.goal && campaign.goal > 0 + ? Math.min(100, Math.round((campaign.totalRaised / campaign.goal) * 100)) + : null; + + return ( +
  • +
    +
    +
    +
    +

    {campaign.name}

    + {campaign.isActive && ( + Live + )} +
    +

    + Tiers: {campaign.tiers.map((t) => `$${t}`).join(" · ")} +

    +
    +
    +

    + ${campaign.totalRaised.toLocaleString()} +

    + {campaign.goal && ( +

    of ${campaign.goal.toLocaleString()} goal

    + )} +
    +
    + + {/* Progress bar */} + {pct !== null && ( +
    +
    +
    +
    +

    {pct}% of goal

    +
    + )} + +
    + + {campaign.isActive && ( + + Open Call Screen → + + )} +
    +
    +
  • + ); + })} +
+ )}
); } diff --git a/packages/client/src/pages/admin/ReportingPage.tsx b/packages/client/src/pages/admin/ReportingPage.tsx index 1749b08..06f7df4 100644 --- a/packages/client/src/pages/admin/ReportingPage.tsx +++ b/packages/client/src/pages/admin/ReportingPage.tsx @@ -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; + staffUser?: { name: string; email: string; role: string } | null; +} + +type Tab = "summary" | "bidders" | "audit"; + +const STATUS_COLOR: Record = { + 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(null); + const [events, setEvents] = useState<{ id: string; name: string; status: string }[]>([]); + const [tab, setTab] = useState("summary"); + const [summary, setSummary] = useState(null); + const [bidders, setBidders] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); + 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(`/api/reporting/events/${eventId}/summary`) + .then(setSummary) + .catch(console.error) + .finally(() => setLoading(false)); + } else if (tab === "bidders") { + api + .get(`/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 (
-
-

Reporting

-

Revenue, sell-through, audit log

+
+
+

Reporting

+

Revenue · Bidders · Audit log

+
+ {events.length > 1 && ( + + )}
-
- Reports — not yet implemented + + {/* Tabs */} +
+ {(["summary", "bidders", "audit"] as Tab[]).map((t) => ( + + ))}
+ + {loading && ( +
Loading…
+ )} + + {/* Summary tab */} + {!loading && tab === "summary" && summary && ( +
+ {/* Revenue grid */} +
+ {[ + { 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 }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* Sell-through */} +
+

Sell-Through

+
+
+
+
+
+
+

+ {summary.items.sellThroughPct}% +

+
+

+ {summary.items.sold} of {summary.items.total} items sold +

+
+
+ )} + + {/* Bidders tab */} + {!loading && tab === "bidders" && ( +
+

{bidders.length} bidders

+
+
+ + + + + + + + + + + + {bidders.map((row) => ( + + + + + + + + ))} + +
Bidder + Paddle + WonTotalInvoice
+

+ {row.bidder.firstName} {row.bidder.lastName} +

+

{row.bidder.email}

+
+ {row.paddleNumber ?? "—"} + + {row.won.count > 0 ? row.won.count : "—"} + + {row.won.total > 0 ? fmt$(row.won.total) : "—"} + + {row.invoice ? ( + + {row.invoice.status.replace("_", " ")} + + ) : ( + + )} +
+
+
+
+ )} + + {/* Audit log tab */} + {!loading && tab === "audit" && ( +
+

{auditTotal} entries

+
+
+ + + + + + + + + + + {auditLogs.map((log) => ( + + + + + + + ))} + +
Action + Entity + + Origin + Time
+

+ {log.action} +

+ {log.staffUser && ( +

{log.staffUser.name}

+ )} +
+ {log.entityType} + + {log.entityId.slice(0, 8)}… + + + {log.originMode ? ( + + {log.originMode} + + ) : ( + + )} + + {new Date(log.createdAt).toLocaleTimeString()} +
+
+
+ + {/* Pagination */} + {auditTotal > 50 && ( +
+ + + Page {auditPage} of {Math.ceil(auditTotal / 50)} + + +
+ )} +
+ )}
); } diff --git a/packages/client/src/pages/bidder/CheckoutPage.tsx b/packages/client/src/pages/bidder/CheckoutPage.tsx index 3875882..153c79b 100644 --- a/packages/client/src/pages/bidder/CheckoutPage.tsx +++ b/packages/client/src/pages/bidder/CheckoutPage.tsx @@ -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 ) ─────────────── + +function PaymentForm({ onSuccess }: { onSuccess: () => void }) { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(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 ( +
+ + {error && ( +
+ {error} +
+ )} + + + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + export default function CheckoutPage() { + const bidder = useAuthStore((s) => s.bidder); + const { eventId } = useEventContext(); + const [data, setData] = useState(null); + const [clientSecret, setClientSecret] = useState(null); + const [stripePromise, setStripePromise] = useState | 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(`/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 ( +
+

Loading checkout…

+
+ ); + } + + if (paymentDone || data?.invoice.status === "paid") { + return ( +
+

Checkout

+
+ 🎉 +

Payment complete!

+

+ Thank you! A receipt has been sent to your email. +

+
+
+ ); + } + + if (!data || data.wonItems.length === 0) { + return ( +
+

Checkout

+
+ No winning bids yet — check back after the auction closes. +
+
+ ); + } + + const { invoice, wonItems } = data; + const remaining = Number(invoice.totalAmount) - Number(invoice.paidAmount); + return (

Checkout

-
- Stripe checkout — not yet implemented + + {/* Won items list */} +
+ {wonItems.map((item) => ( +
+
+

Lot {item.lotNumber}

+

{item.title}

+
+

+ ${Number(item.currentHighBid ?? 0).toLocaleString()} +

+
+ ))} +
+

Total due

+

+ ${remaining.toLocaleString()} +

+
+ + {/* Payment section */} + {invoice.status === "partially_paid" && ( +
+ ${Number(invoice.paidAmount).toLocaleString()} already paid ·{" "} + ${remaining.toLocaleString()} remaining +
+ )} + + {clientSecret && stripePromise ? ( +
+

Payment details

+ + setPaymentDone(true)} /> + +
+ ) : !stripePromise ? ( +
+ Stripe is not configured. Please ask staff to process your payment. +
+ ) : ( + + )}
); } diff --git a/packages/client/src/pages/bidder/ProfilePage.tsx b/packages/client/src/pages/bidder/ProfilePage.tsx index 5e71058..6e8d73d 100644 --- a/packages/client/src/pages/bidder/ProfilePage.tsx +++ b/packages/client/src/pages/bidder/ProfilePage.tsx @@ -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=&e= */ +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(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 (
@@ -28,14 +47,43 @@ export default function ProfilePage() {
)} - {/* Digital paddle placeholder */} -
- Digital paddle QR code — not yet implemented + {/* Digital paddle */} + {bidder?.paddleNumber && ( +
+
+

+ Digital Paddle +

+
+
+

+ {bidder.paddleNumber} +

+

{bidderName(bidder)}

+
+
+ )} + + {/* QR code for check-in */} +
+

Check-In QR

+

+ Show this to staff at the check-in desk +

+
+ +
+ {!qrReady && ( +
+ )}
{/* Sign out */} + ) : ( + + )}
- +
+ {/* Camera viewfinder */} + {scanMode && ( +
+