-
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 }) => (
+
+ ))}
+
+
+ {/* Sell-through */}
+
+
Sell-Through
+
+
+
+ {summary.items.sellThroughPct}%
+
+
+
+ {summary.items.sold} of {summary.items.total} items sold
+
+
+
+ )}
+
+ {/* Bidders tab */}
+ {!loading && tab === "bidders" && (
+
+
{bidders.length} bidders
+
+
+
+
+
+ | Bidder |
+
+ Paddle
+ |
+ Won |
+ Total |
+ Invoice |
+
+
+
+ {bidders.map((row) => (
+
+ |
+
+ {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
+
+
+
+
+
+ | Action |
+
+ Entity
+ |
+
+ Origin
+ |
+ Time |
+
+
+
+ {auditLogs.map((log) => (
+
+ |
+
+ {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 (
+
+ );
+}
+
+// ── 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 (
+
+ );
+ }
+
+ 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 && (
+
+
+
+
+ {bidder.paddleNumber}
+
+
{bidderName(bidder)}
+
+
+ )}
+
+ {/* QR code for check-in */}
+
+
Check-In QR
+
+ Show this to staff at the check-in desk
+
+
+
+
+ {!qrReady && (
+
+ )}
{/* Sign out */}