diff --git a/README.md b/README.md index dc811de..ca2693f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ event-night resilience. | Cache/Queue| Redis (optional) | | Auth | Email magic links + Twilio Verify SMS OTP | | Payments | Stripe Payment Element / Payment Intents | -| Media | S3-compatible presigned uploads | +| Media | Local disk (multer) served as static files | | Deploy | Docker Compose (Unraid / Linux VM) | ## Quick Start (development) @@ -33,7 +33,7 @@ docker compose -f docker-compose.dev.yml up -d # 3. Configure environment cp .env.example .env -# Edit .env with your Stripe, Twilio, SMTP, and storage keys +# Edit .env with your Stripe, Twilio, and SMTP keys # 4. Migrate database and seed demo data npm run db:migrate @@ -68,8 +68,8 @@ The full product specification lives in [`STORYBID.md`](./STORYBID.md). | Phase | Focus | Status | |-------|------------------------------------------------|---------| -| 1 | Foundation – monorepo, auth, org/event models | πŸ— scaffold | -| 2 | Live Auction – auctioneer console, bidder view | ⬜ todo | +| 1 | Foundation – monorepo, auth, org/event models | βœ… done | +| 2 | Live Auction – auctioneer console, bidder view | βœ… done | | 3 | Silent Auction – catalog, timers, outbid | ⬜ todo | | 4 | Offline Resilience – PWA, outbox, failover | ⬜ todo | | 5 | Event Ops – check-in, checkout, fund-a-need | ⬜ todo | diff --git a/demo/demo.html b/demo/demo.html new file mode 100644 index 0000000..51fb527 --- /dev/null +++ b/demo/demo.html @@ -0,0 +1,1289 @@ + + + + + + +Storybid β€” Storybook Farm Charity Auction Platform + + + + + +
+
+ + +
+
+ Built for Storybook Farm +

A charity auction night that never goes dark.

+

+ Storybid is a self-hosted live + silent auction platform purpose-built for + Storybook Farm. Bidders bid from their phones, the auctioneer drives the + room, and the whole event keeps running on local Wi-Fi if the internet drops. +

+ + +
+
1 system
Live + silent + fund-a-need
+
0 app store
Mobile web, install as PWA
+
100%
Self-hosted & offline-ready
+
2 of 6
Build phases shipped
+
+
+ + +
+
+
+
9:41● ● ● ●
+
+

Welcome back

+

Sarah Whitman

+
+ #142 + Your paddle +
+
+
+
+ 🌿 +
+
Storybook Farm Gala
+
Auction is live
+
+ +
+
+
πŸŽ™
Live
Bid in real time
+
🏷️
Silent
Browse & bid
+
πŸ“‹
My Bids
3 active
+
πŸ’³
Checkout
Pay & collect
+
+
+
+
+
+
+
+
+ + +
+
+ The problem +

Off-the-shelf auction tools weren't built for Storybook Farm.

+

+ Existing platforms charge per-event SaaS fees, hold donor data on someone else's + cloud, and quietly assume the venue has reliable Wi-Fi. None of those are safe + bets for a rural fundraiser where a flaky uplink could end the night early β€” + and where each lost bid is real money that didn't reach the kids. +

+ +
+
+
⚠️
+

Bid loss when Wi-Fi blinks

+

SaaS auction tools hard-fail when their server is unreachable. A 60-second outage during a $5,000 lot is a $5,000 problem.

+
+
+
πŸ’Έ
+

Per-event fees add up

+

Most platforms take 1–3% of every transaction plus monthly minimums. Over a few galas, that pays for an entire system you'd own.

+
+
+
πŸ”’
+

Donor data lives elsewhere

+

Bidder lists, contact info, and giving history sit in a vendor's database. Storybid keeps every record on a server you control.

+
+
+
+
+ + +
+
+ What it does today +

Everything you need to run gala night, in one place.

+

+ One platform covers the live auctioneer call, the silent auction catalog, + paddle-raise / fund-a-need, check-in, payment, and end-of-night reporting. + Guests use their phones β€” no app store, no downloads. +

+ +
+
+ Shipped +
πŸŽ™
+

Live auction console

+

Auctioneer-controlled increments, single-tap bid button on every phone, going-once / going-twice / sold workflow with winner confirmation.

+
+
+ In progress +
🏷️
+

Silent auction catalog

+

Real-time current high bid, countdown timers, configurable section closing, soft-close to stop sniping, and instant outbid alerts.

+
+
+ Shipped +
πŸ“Ί
+

Display board

+

Big-screen view for the projector or TV with current lot, current bid, paddle number, branding, and an optional fundraising thermometer.

+
+
+ Planned +
πŸ’–
+

Fund-a-need / paddle raise

+

Donation tiers with live totals on screen, suggested amounts, and one-tap commitment from any guest's phone.

+
+
+ Planned +
πŸͺͺ
+

QR check-in & digital paddles

+

Guests scan in at the door, get a digital paddle on their phone, and connect their card for fast checkout when the night ends.

+
+
+ Planned +
πŸ’³
+

Stripe checkout & receipts

+

End-of-night payment, saved cards, donor receipts, and a clean audit trail for accounting and donor acknowledgment.

+
+
+ Shipped +
πŸ› οΈ
+

Spotter mode

+

Volunteers can enter floor bids by paddle number from their own phone β€” table captains and roving spotters stay synced with the auctioneer.

+
+
+ Shipped +
πŸ”
+

Email + SMS sign-in

+

Magic-link email login for staff and donors who prefer it; Twilio Verify SMS OTP for guests who'd rather use their phone number.

+
+
+ Planned +
πŸ“Š
+

Reporting & donor history

+

Revenue, sell-through, bidder activity, donor giving history, and one-click exports for the accountant after the event.

+
+
+
+
+ + +
+
+
+
+ The differentiator +

If the internet drops mid-auction, the auction doesn't drop.

+

+ Storybid runs on a small server on-site. Phones automatically fall back from the + public address to a local hostname on the venue Wi-Fi, and any bid that can't reach + the server right away is queued on the device and synced the moment connectivity + returns β€” with an audit-trail tag so the bid history is never in doubt. +

+
+
+
1
Public URL
storybid.storybookfarm.org
+
2
Local DNS
bid.gala.lan over Wi-Fi
+
3
Offline queue
Phone holds & syncs later
+
+
+
+
+ + +
+
+ Who uses it +

A focused screen for every person in the room.

+

+ Storybid ships separate, optimized views for each role on event night, so the + auctioneer, spotters, check-in volunteers, and the bidders all see only what + they need β€” no admin clutter, no accidental clicks during the call. +

+ +
+
πŸ‘€

Bidder

Mobile-first home, live screen, silent catalog, my bids, watchlist, and checkout β€” all in one PWA.

+
πŸŽ™

Auctioneer

Lock-screen console with current lot, current bid, called amount, sold workflow, and bidder paddle.

+
πŸ‘‹

Spotter

Tap-paddle interface for floor volunteers to log bids by paddle number on behalf of in-room guests.

+
πŸͺͺ

Check-in staff

QR scan, search, paddle assignment, payment-on-file confirmation β€” keeps the door line moving.

+
πŸ“Ί

Display board

Read-only big-screen view for the projector or TV with branding, current lot, and fund-a-need totals.

+
πŸ’Ό

Event manager

Admin console for events, items, bidders, donors, sponsors, increment rules, and reporting exports.

+
πŸ›‘οΈ

Org admin

Branding, Stripe keys, SMS provider config, DNS / failover settings, role assignments, and audit log.

+
πŸ’³

Cashier

Staff-assisted checkout, partial settlements, receipt printing, and end-of-night reconciliation.

+
+
+
+ + +
+
+ Screen previews +

A peek at the surfaces that matter most.

+

+ These are the views guests, auctioneers, and the room will actually see. + Final visuals will use Storybook Farm branding, photography, and tone of voice. +

+ +
+ +
+
+
+
Bidder Β· Live auction
+ Shipped +
+
+
Lot 14
+
Weekend at the Lake House
+
Donated by The Henderson Family
+
● Bidding open
+
Current bid
+
$3,200
+ +
+
+ + +
+
+
+
Bidder Β· Silent auction
+ In progress +
+
+
+
🎨
Custom Family Portrait
$425 Β· top bid
Closes in 22:14
+
🍷
Sonoma Wine Trio
$280 Β· top bid
Closes in 31:08
+
πŸ›Ά
Guided Kayak Day
$190 Β· you're top
Closes in 12:45
+
πŸ“š
Storytime Library Set
$140 Β· top bid
Closes in 41:33
+
+
+
+ + +
+
+
+
Staff Β· Auctioneer console
+ Shipped +
+
+
+
+
Lot 14 Β· Active
+
⏱ 02:18 on lot
+
+
Weekend at the Lake House
+
+
Current bid
$3,200
+
Next call
$3,400
+
High bidder
Paddle #142
+
Bidders in
7
+
+
+
+ Increment
+
Going once
+
Going twice
+
SOLD
+
+
+
+
+ + +
+
+
+
Room Β· Display board
+ Shipped +
+
+
+
Storybook Farm Charity Gala
+
Weekend at the Lake House
+
Current bid
+
$3,200
+
High bidder Β· Paddle #142
+
+
Fund-a-Need Β· $34,200 of $50,000
+
+
+
+ + +
+
+
+
Door Β· Check-in flow
+ Planned +
+
+
+
+
βœ“
+
Scan QR or look up by name
Sarah Whitman Β· Table 7
+ Done +
+
+
βœ“
+
Assign paddle
Paddle #142 issued
+ Done +
+
+
3
+
Card on file (Stripe)
Tap to capture for fast checkout
+ Now +
+
+
4
+
Send digital paddle to phone
SMS link, magic email, or QR
+ Next +
+
+
+
+ + +
+
+
+
Admin Β· Live reporting
+ Planned +
+
+
+
+
Raised tonight
$84,250
β–² on goal
+
Sell-through
87%
42 of 48 lots
+
Active bidders
128
+9 since 7pm
+
Avg bid
$310
β–² vs last yr
+
+
+
+
+
+
+
+
+
+
+
+
+
Hourly bid volume Β· 6pm–10pm
+
+
+
+ + +
+
+
+
Bidder Β· Outbid & alerts
+ Planned +
+
+
+
+
⚠️
+
+
You've been outbid on Sonoma Wine Trio
+
New top bid is $300. Tap to bid $320.
+
Just now Β· push
+
+
+
+
πŸ†
+
+
You won Storytime Library Set
+
Final bid $140. We'll charge your card on file.
+
2 min ago Β· email + SMS
+
+
+
+
⏱
+
+
Silent auction closes in 5 min
+
3 lots in your watchlist still open.
+
8 min ago Β· in-app
+
+
+
+
+
+ + +
+
+
+
Bidder Β· Fund-a-Need
+ Planned +
+
+
Paddle Raise
+
Send a child to summer camp
+
+ + + + + + +
+
+ 68% of $50,000 goal Β· 142 donors so far +
+
+
+
+
+
+ + +
+
+ What's coming +

Planned features that aren't shipped yet.

+

+ The four remaining build phases turn what's already running into a complete + event-night system. Here's the work the organization is being asked to greenlight. +

+ +
+
+
🏷️

Silent auction engine

+

Catalog with item pages, current high bid, minimum next bid, configurable closing windows by section/table/category, and bidder watchlists.

+ Phase 3 Β· in flight +
+
+
⏱

Soft-close / auto-extend

+

If a valid bid lands in the last 60 seconds, the lot extends automatically β€” preventing last-second sniping and matching common silent-auction practice.

+ Phase 3 +
+
+
πŸ””

Outbid alerts

+

Channel-aware notifications: in-app banner, web push where supported, email confirmations, and SMS for time-sensitive outbid and checkout-ready alerts.

+ Phase 3 +
+
+
πŸ“²

PWA install & offline shell

+

Service worker caches the app shell so guests can keep browsing even with no signal; a Dexie/IndexedDB outbox queues bids and syncs them on reconnect.

+ Phase 4 +
+
+
πŸ“‘

FQDN β†’ LAN failover

+

Connection manager attempts the public URL first, then falls back to the UniFi local DNS hostname on the venue Wi-Fi. Origin of every bid is tagged for the audit trail.

+ Phase 4 +
+
+
πŸ“œ

Audit log with origin tagging

+

Every bid records device id, client timestamp, server-received timestamp, sequence number, and origin (public, local DNS, local IP, offline queue) for full traceability.

+ Phase 4 +
+
+
πŸ“₯

CSV bidder import

+

Bulk-load donor lists from previous events; assign paddle numbers, table seating, and preferred contact channels in one upload.

+ Phase 5 +
+
+
πŸͺͺ

QR check-in & digital paddles

+

Guests pre-register, get a QR code by email, and scan in at the door. Digital paddle appears on their phone β€” no laminated cards to print or lose.

+ Phase 5 +
+
+
πŸ’–

Fund-a-Need / paddle raise

+

Donation tier setup, live total updates on the display board, and one-tap commitment from any guest's phone. Optional matching-gift tracking.

+ Phase 5 +
+
+
πŸ’³

Stripe checkout

+

Payment Element + saved cards, end-of-night batch charge, staff-assisted cashier station, immediate pay-now for buy-it-now items, and partial settlement.

+ Phase 5 +
+
+
🧾

Donor receipts & acknowledgments

+

Auto-generated tax-deductible receipts with the fair-market-value split, plus a personalized donor thank-you email after the event.

+ Phase 5 +
+
+
πŸ“Š

Reporting & exports

+

Revenue, sell-through, top donors, fund-a-need totals, and bidder activity. CSV exports for accounting and the donor-relationship system.

+ Phase 5 +
+
+
🎁

Buy-it-now & sponsorships

+

Fixed-price offers (raffle tickets, dinner add-ons, sponsorship packages) sold straight from the catalog without going through the auction flow.

+ Phase 5 +
+
+
πŸ–ΌοΈ

Rich item media

+

Multiple images, video walkthroughs, donor-provided documents, and external embeds for unique lots like vacation packages or experience auctions.

+ Phase 5 +
+
+
β™Ώ

Accessibility hardening

+

High-contrast mode for low-light ballrooms, larger touch targets, screen-reader audit, and noise-aware notifications for hearing-impaired guests.

+ Phase 6 +
+
+
πŸ‹οΈ

Load testing & backups

+

Realistic bidder-count load tests, automated nightly backups, point-in-time restore, and a tested disaster-recovery procedure for event night.

+ Phase 6 +
+
+
πŸ“˜

Operator manual

+

Printable preflight checklist, per-role staff runbooks, and a one-page "what to do if X happens" sheet for event-night volunteers.

+ Phase 6 +
+
+
🌐

UniFi event-network guide

+

Documented SSID setup, local DNS records, device prioritization, and on-site UPS recommendations so the venue Wi-Fi is bulletproof.

+ Phase 6 +
+
+
+
+ + +
+
+ Build plan +

Where the build stands today.

+

+ Storybid is built in six clear phases. The foundation and live auction core + are already complete. The remaining four phases finish silent auction, + offline resilience, event-night ops, and hardening. +

+ +
+
+
Phase 1Done
+

Foundation

+

Monorepo, Docker deploy, organization & event models, admin shell, authentication.

+
    +
  • React + Vite client scaffolded
  • +
  • Express + Prisma server
  • +
  • Magic-link & SMS OTP login
  • +
  • Admin shell & routing
  • +
+
+
+
Phase 2Done
+

Live auction core

+

Auctioneer console, bidder live screen, increment engine, spotter mode, display board.

+
    +
  • Socket.io live bid stream
  • +
  • Going-once/twice/sold workflow
  • +
  • Spotter paddle-entry view
  • +
  • Big-screen display board
  • +
+
+
+
Phase 3In flight
+

Silent auction

+

Catalog, countdowns, soft-close, outbid notifications, watchlists.

+
    +
  • Item pages with current high bid
  • +
  • Per-section closing windows
  • +
  • Soft-close auto-extend
  • +
  • Push / email / SMS outbid alerts
  • +
+
+
+
Phase 4Next
+

Offline resilience

+

PWA shell, IndexedDB outbox, FQDN-to-LAN failover, sync engine, audit tagging.

+
    +
  • Workbox service worker
  • +
  • Dexie outbox queue
  • +
  • Local DNS failover
  • +
  • Origin-tagged audit log
  • +
+
+
+
Phase 5Next
+

Event ops & checkout

+

QR check-in, digital paddles, fund-a-need module, Stripe checkout, receipts.

+
    +
  • QR registration + door scan
  • +
  • Fund-a-Need / paddle raise
  • +
  • Stripe Payment Element
  • +
  • Tax-receipt email automation
  • +
+
+
+
Phase 6Next
+

Hardening & polish

+

Load test, accessibility, backups, runbooks, operator manual, event-day checklist.

+
    +
  • Realistic load testing
  • +
  • Backups + restore drill
  • +
  • Accessibility audit
  • +
  • Printed staff runbook
  • +
+
+
+ +

Technical stack

+
+
Layer
Choice
+
Client
React 18 + TypeScript + Vite + Tailwind, installable as a PWA
+
Real-time
Socket.io for live bid streams and silent auction outbid alerts
+
Server
Node.js + Express + TypeScript, Prisma ORM, PostgreSQL
+
Offline
Workbox service worker + Dexie/IndexedDB outbox, LAN failover
+
Auth
Email magic links + Twilio Verify SMS OTP for guest bidders
+
Payments
Stripe Payment Element β€” no card data ever touches our server
+
Hosting
Docker Compose on a self-hosted server with on-site failover
+
Network
UniFi local DNS records + dedicated event SSID with battery backup
+
+
+
+ + +
+
+ Data & security +

Donor data stays with Storybook Farm.

+

+ Because Storybid is self-hosted, every bidder record, donation, and audit-log + entry lives on a server the organization controls. The only third parties in + the loop are Stripe (payments) and the SMS provider for OTP β€” both narrowly scoped. +

+ +
+
+
πŸ”
+

PCI-light by design

+

Stripe Payment Element handles card entry in an iframe Stripe owns. Card numbers never reach our database, our server, or our logs.

+
+
+
πŸ“œ
+

Append-only audit log

+

Every bid, status change, and admin action is logged with timestamp, actor, device id, and origin tag. Disputes get a clean, defensible record.

+
+
+
πŸ‘₯
+

Role-based access

+

Admin, event manager, auctioneer, spotter, check-in, cashier, and bidder each see only their own surface β€” accidental misclicks during the call are nearly impossible.

+
+
+
πŸ›‘οΈ
+

Server-side bid validation

+

The server is the source of truth for accepted bids. Rate-limiting, increment validation, and signed tokens protect against spoofed clients and double-submits.

+
+
+
πŸ’Ύ
+

Backups you actually own

+

Nightly database snapshots to a location of the organization's choosing, plus a tested restore procedure documented in the operator manual.

+
+
+
🏠
+

Self-hosted on Docker

+

One docker compose up on Unraid or a small Linux VM. No vendor lock-in, no monthly minimums, no surprise renewals.

+
+
+
+
+ + +
+
+ Indicative timeline +

From approval to gala-ready in four working blocks.

+

+ Below is a rough cadence assuming approval is granted now. Exact dates depend + on event date and Storybook Farm's preferred review checkpoints; this is a + planning frame, not a contract. +

+ +
+
+
Block AWeeks 1–3
+

Finish silent auction

+

Phase 3 wraps: catalog, soft-close, outbid alerts, watchlists. First end-to-end staff demo at week 3.

+
+
+
Block BWeeks 4–6
+

Offline & LAN failover

+

Phase 4: PWA shell, outbox queue, FQDN→LAN failover, simulated internet-drop drill recorded for review.

+
+
+
Block CWeeks 7–10
+

Check-in & checkout

+

Phase 5: QR check-in, fund-a-need, Stripe end-to-end, donor receipts, reporting exports.

+
+
+
Block DWeeks 11–12
+

Dress rehearsal & hardening

+

Phase 6: load test, accessibility pass, backup/restore drill, printed runbook, full mock-event night.

+
+
+
+
+ + +
+
+ Questions the board usually asks +

FAQ

+ +
+
+ What does it cost the organization to run? +

The software itself has no per-event fee. Running costs are the small server (a Linux VM or Unraid host the org already owns), Stripe's standard 2.9% + 30Β’ on each charge, and a few dollars per event in Twilio SMS for guest sign-in. There are no SaaS subscriptions, seat licenses, or transaction surcharges from us.

+
+
+ What happens if the venue Wi-Fi has no internet at all? +

The on-site server keeps running, the local Wi-Fi keeps working, and phones automatically fall back from the public URL to the local DNS hostname. The auction continues normally β€” guests can still bid, the auctioneer can still call, and the display board still updates. When the WAN comes back, the server pushes the night's data to its public state and any device-side queued bids sync up.

+
+
+ Do guests need to download an app? +

No. Storybid is a PWA β€” a website the phone treats like an app. Guests scan a QR code or follow an SMS link, the browser opens, and that's it. They can optionally tap "Add to Home Screen" to get an icon, but it's not required.

+
+
+ How do guests log in? +

Two options: an email magic link (we send a one-tap login link to the email on file), or an SMS one-time code via Twilio Verify. Donors and staff who use Storybid often will tend toward email; walk-up guests usually prefer SMS. No passwords to manage either way.

+
+
+ Can we run more than one event a year? +

Yes. The platform is built around one organization with many events over time β€” gala night, summer auction, online-only catalogs, sponsorship campaigns. Donor and item history follow the organization, so year-over-year reporting is built in.

+
+
+ What about disputes β€” "I bid first, the system didn't take it"? +

Every bid attempt is logged with the device id, the client timestamp, the server-received timestamp, a client sequence number, and an origin tag (public URL, local DNS, local IP, or offline queue). If a guest disputes a result, the audit log shows exactly when their device sent the bid, when the server saw it, and how it was processed.

+
+
+ Where does donor data live, and who can see it? +

Everything lives on Storybook Farm's own server. Stripe sees only the data needed to charge a card (name, email, amount). Twilio sees only the phone number for OTP. Storybid itself isn't a service β€” there's no central vendor with a copy of your donor list.

+
+
+ Is this ready for the next gala? +

Phases 1 and 2 are live and demo-able now. The remaining four phases (silent auction, offline resilience, check-in & checkout, hardening) are scoped and sequenced. Approval today puts the system on track for the next major gala with a full dress rehearsal beforehand.

+
+
+
+
+ + +
+
+
+
+ For board & staff review +

Ready to greenlight Storybid?

+

+ We're asking the organization to approve the direction shown here so the + remaining phases β€” silent auction, offline resilience, check-in & checkout, + and hardening β€” can be scheduled and finished in time for gala night. +

+ +
+
    +
  • βœ“
    Brand fit
    Storybook Farm green & gold, clean and donor-appropriate.
  • +
  • βœ“
    Both auction modes
    Live + silent + fund-a-need in one event, one app.
  • +
  • βœ“
    Resilient to bad Wi-Fi
    Local server, LAN failover, offline bid queue with audit trail.
  • +
  • βœ“
    Self-hosted & owned
    No per-event SaaS fees; data stays with the organization.
  • +
  • βœ“
    PCI-light payments
    Stripe handles cards; we never store card data.
  • +
  • βœ“
    Year-over-year history
    Donor records and item history follow the organization across events.
  • +
+
+
+
+ + + + + diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index e6a40d5..4a36240 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,5 +1,8 @@ import { Routes, Route, Navigate } from "react-router-dom"; -import { ConnectivityBanner } from "./components/ConnectivityBanner.js"; + +// Layouts +import BidderLayout from "./components/BidderLayout.js"; +import AdminLayout from "./components/AdminLayout.js"; // Bidder-facing pages import HomePage from "./pages/bidder/HomePage.js"; @@ -14,7 +17,7 @@ import ProfilePage from "./pages/bidder/ProfilePage.js"; import LoginPage from "./pages/auth/LoginPage.js"; import VerifyPage from "./pages/auth/VerifyPage.js"; -// Staff pages +// Staff pages (full-screen, no shared shell) import AuctioneerPage from "./pages/staff/AuctioneerPage.js"; import SpotterPage from "./pages/staff/SpotterPage.js"; import CheckInPage from "./pages/staff/CheckInPage.js"; @@ -31,39 +34,40 @@ import FundANeedPage from "./pages/admin/FundANeedPage.js"; export default function App() { return ( - <> - - - {/* Auth */} - } /> - } /> + + {/* ── Auth (no layout) ── */} + } /> + } /> - {/* Bidder */} - } /> - } /> - } /> + {/* ── Staff tools (full-screen, no layout chrome) ── */} + } /> + } /> + } /> + } /> + + {/* ── Bidder shell ── */} + }> + } /> + } /> + } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + - {/* Staff – optimized single-task views */} - } /> - } /> - } /> - } /> + {/* ── Admin shell ── */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Admin */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - + } /> + ); } diff --git a/packages/client/src/components/AdminLayout.tsx b/packages/client/src/components/AdminLayout.tsx new file mode 100644 index 0000000..282781e --- /dev/null +++ b/packages/client/src/components/AdminLayout.tsx @@ -0,0 +1,145 @@ +/** + * AdminLayout β€” responsive sidebar shell for admin/management pages. + * + * Desktop (md+): fixed left sidebar (240px) + scrollable content area + * Mobile: collapsible drawer triggered by hamburger in top bar + */ +import { useState } from "react"; +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useAuthStore } from "../store/auth.js"; + +// ── Nav items ────────────────────────────────────────────────────────────────── +const ADMIN_NAV = [ + { to: "/admin", label: "Dashboard", emoji: "πŸ“Š", exact: true }, + { to: "/admin/events", label: "Events", emoji: "πŸ—“οΈ", exact: false }, + { to: "/admin/items", label: "Items", emoji: "🏷️", exact: false }, + { to: "/admin/bidders", label: "Bidders", emoji: "🎟️", exact: false }, + { to: "/admin/checkout", label: "Checkout", emoji: "πŸ’³", exact: false }, + { to: "/admin/reporting", label: "Reporting", emoji: "πŸ“ˆ", exact: false }, + { to: "/admin/fund-a-need",label: "Fund-a-Need", emoji: "πŸ’š", exact: false }, +] as const; + +const STAFF_NAV = [ + { to: "/staff/auctioneer", label: "Auctioneer", emoji: "πŸŽ™" }, + { to: "/staff/spotter", label: "Spotter", emoji: "πŸ‘€" }, + { to: "/staff/check-in", label: "Check-in", emoji: "βœ…" }, + { to: "/display", label: "Display", emoji: "πŸ“Ί" }, +] as const; + +// ── Sidebar content ──────────────────────────────────────────────────────────── +function SidebarContent({ onClose }: { onClose?: () => void }) { + const navigate = useNavigate(); + const logout = useAuthStore((s) => s.logout); + + const linkCls = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${ + isActive + ? "bg-brand-700 text-white" + : "text-gray-600 hover:bg-brand-50 hover:text-brand-700" + }`; + + return ( +
+ {/* Logo / brand */} +
+ +
+ + + + {/* Footer: logout */} +
+ +
+
+ ); +} + +// ── Main layout ──────────────────────────────────────────────────────────────── +export default function AdminLayout() { + const [drawerOpen, setDrawerOpen] = useState(false); + + return ( +
+ {/* ── Desktop sidebar (hidden on mobile) ── */} + + + {/* ── Mobile drawer overlay ── */} + {drawerOpen && ( +
setDrawerOpen(false)} + > + {/* Backdrop */} +
+ {/* Drawer panel */} + +
+ )} + + {/* ── Main area ── */} +
+ {/* Mobile top bar */} +
+ + STORYBOOK FARM + Admin +
+ + {/* Page content */} +
+ +
+
+
+ ); +} + +function HamburgerIcon() { + return ( + + + + ); +} diff --git a/packages/client/src/components/BidderLayout.tsx b/packages/client/src/components/BidderLayout.tsx new file mode 100644 index 0000000..695b513 --- /dev/null +++ b/packages/client/src/components/BidderLayout.tsx @@ -0,0 +1,174 @@ +/** + * BidderLayout β€” shell wrapping all attendee-facing pages. + * + * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + * β”‚ Header (logo + connectivity) β”‚ + * β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + * β”‚ β”‚ + * β”‚ β”‚ + * β”‚ β”‚ + * β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + * β”‚ Bottom nav (5 tabs) β”‚ + * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + */ +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useConnectivityStore } from "../store/connectivity.js"; +import { useAuthStore } from "../store/auth.js"; + +// ── Nav tab definitions ──────────────────────────────────────────────────────── +const NAV_TABS = [ + { to: "/", label: "Home", icon: HomeIcon }, + { to: "/live", label: "Live", icon: MicIcon }, + { to: "/silent", label: "Silent", icon: TagIcon }, + { to: "/my-bids", label: "My Bids", icon: ListIcon }, + { to: "/profile", label: "Profile", icon: UserIcon }, +] as const; + +// ── Inline SVG icon components ───────────────────────────────────────────────── +function HomeIcon({ cls }: { cls: string }) { + return ( + + + + ); +} +function MicIcon({ cls }: { cls: string }) { + return ( + + + + ); +} +function TagIcon({ cls }: { cls: string }) { + return ( + + + + + ); +} +function ListIcon({ cls }: { cls: string }) { + return ( + + + + ); +} +function UserIcon({ cls }: { cls: string }) { + return ( + + + + ); +} + +// ── Connectivity dot in header ───────────────────────────────────────────────── +function ConnectivityDot() { + const status = useConnectivityStore((s) => s.status); + const colorMap = { connected: "bg-emerald-400", local: "bg-gold-400", offline: "bg-red-400" }; + const labelMap = { connected: "Online", local: "Local", offline: "Offline" }; + return ( + + + {labelMap[status]} + + ); +} + +// ── Main layout ──────────────────────────────────────────────────────────────── +export default function BidderLayout() { + const navigate = useNavigate(); + const bidder = useAuthStore((s) => s.bidder); + + return ( +
+ {/* ── Header ── */} +
+
+ {/* Brand name */} + + + {/* Right side: connectivity + paddle */} +
+ + {bidder?.paddleNumber && ( + + #{bidder.paddleNumber} + + )} +
+
+
+ + {/* ── Offline/local banner (only non-connected) ── */} + + + {/* ── Page content ── */} +
+ +
+ + {/* ── Bottom navigation ── */} + +
+ ); +} + +// ── Inline offline banner (below header) ────────────────────────────────────── +function OfflineBanner() { + const status = useConnectivityStore((s) => s.status); + if (status === "connected") return null; + + const configs = { + local: { bg: "bg-gold-500", text: "Local network β€” offline-capable" }, + offline: { bg: "bg-red-500", text: "Offline β€” bids will sync when reconnected" }, + }; + const cfg = configs[status as keyof typeof configs]; + if (!cfg) return null; + + return ( +
+ {cfg.text} +
+ ); +} diff --git a/packages/client/src/components/ConnectivityBanner.tsx b/packages/client/src/components/ConnectivityBanner.tsx index 56eaccc..de019b9 100644 --- a/packages/client/src/components/ConnectivityBanner.tsx +++ b/packages/client/src/components/ConnectivityBanner.tsx @@ -1,21 +1,26 @@ +/** + * Thin connectivity banner β€” used on pages outside the BidderLayout shell + * (auth pages, staff tools, display board). + * + * Hidden when fully connected. + */ import { useConnectivityStore } from "../store/connectivity.js"; -const labels: Record = { - connected: { text: "Connected", className: "bg-green-500" }, - local: { text: "Local network – offline-capable", className: "bg-yellow-500" }, - offline: { text: "Offline – bids will sync when reconnected", className: "bg-red-500" }, -}; +const CONFIGS = { + local: { bg: "bg-gold-500", text: "Local network β€” offline-capable" }, + offline: { bg: "bg-red-500", text: "Offline β€” bids will sync when reconnected" }, +} as const; export function ConnectivityBanner() { const status = useConnectivityStore((s) => s.status); - if (status === "connected") return null; - const { text, className } = labels[status]!; + const cfg = CONFIGS[status as keyof typeof CONFIGS]; + if (!cfg) return null; return ( -
- {text} +
+ {cfg.text}
); } diff --git a/packages/client/src/hooks/useAuctioneerControls.ts b/packages/client/src/hooks/useAuctioneerControls.ts new file mode 100644 index 0000000..741b922 --- /dev/null +++ b/packages/client/src/hooks/useAuctioneerControls.ts @@ -0,0 +1,156 @@ +/** + * Auctioneer console hook. + * + * Manages the full live auction control flow: + * - Subscribes to all live-auction server events + * - Emits auctioneer_* socket events for each control action + * - Keeps a local snapshot of the current item, called amount, and bid stream + */ +import { useState, useEffect, useCallback } from "react"; +import { getSocket } from "../lib/socket.js"; +import { api } from "../lib/api.js"; +import type { AuctionItem, Bid, ItemState } from "@storybid/shared"; + +export interface AuctioneerState { + items: AuctionItem[]; // all lots for the auction + currentItem: AuctionItem | null; + currentBid: number | null; + calledAmount: number | null; + state: ItemState | null; + recentBids: Bid[]; + loading: boolean; +} + +export function useAuctioneerControls(eventId: string, auctionId: string) { + const [state, setState] = useState({ + items: [], + currentItem: null, + currentBid: null, + calledAmount: null, + state: null, + recentBids: [], + loading: true, + }); + + // Load item list on mount + useEffect(() => { + api + .get(`/api/items?auctionId=${auctionId}`) + .then((items) => setState((s) => ({ ...s, items, loading: false }))) + .catch(() => setState((s) => ({ ...s, loading: false }))); + }, [auctionId]); + + // Real-time subscriptions + useEffect(() => { + const socket = getSocket(); + socket.emit("join_event", eventId); + + socket.on("item_activated", ({ item }) => { + setState((s) => ({ + ...s, + currentItem: item, + currentBid: item.currentHighBid, + calledAmount: item.openingBid, + state: item.state, + recentBids: [], + // Keep item list in sync + items: s.items.map((i) => (i.id === item.id ? item : i)), + })); + }); + + socket.on("next_live_bid", ({ amount }) => { + setState((s) => ({ ...s, calledAmount: amount })); + }); + + socket.on("live_bid_accepted", ({ bid, item }) => { + setState((s) => ({ + ...s, + currentItem: item, + currentBid: item.currentHighBid, + state: item.state, + recentBids: [bid, ...s.recentBids].slice(0, 20), + items: s.items.map((i) => (i.id === item.id ? item : i)), + })); + }); + + socket.on("item_state_changed", ({ itemId, state: newState }) => { + setState((s) => ({ + ...s, + state: s.currentItem?.id === itemId ? newState : s.state, + items: s.items.map((i) => (i.id === itemId ? { ...i, state: newState } : i)), + })); + }); + + socket.on("item_sold", ({ itemId, amount }) => { + setState((s) => ({ + ...s, + state: s.currentItem?.id === itemId ? "sold" : s.state, + currentBid: s.currentItem?.id === itemId ? amount : s.currentBid, + items: s.items.map((i) => + i.id === itemId ? { ...i, state: "sold", currentHighBid: amount } : i, + ), + })); + }); + + return () => { + socket.emit("leave_event", eventId); + socket.off("item_activated"); + socket.off("next_live_bid"); + socket.off("live_bid_accepted"); + socket.off("item_state_changed"); + socket.off("item_sold"); + }; + }, [eventId]); + + // ── Controls ────────────────────────────────────────────────────────────────── + + const activateItem = useCallback((itemId: string) => { + getSocket().emit("auctioneer_activate_item", itemId); + }, []); + + const callNextBid = useCallback((itemId: string, amount: number) => { + getSocket().emit("auctioneer_call_next_bid", { itemId, amount }); + setState((s) => ({ ...s, calledAmount: amount })); + }, []); + + const acceptBid = useCallback((itemId: string, bidderId: string, amount: number) => { + getSocket().emit("auctioneer_accept_bid", { itemId, bidderId, amount }); + }, []); + + const goingOnce = useCallback((itemId: string) => { + getSocket().emit("auctioneer_going_once", itemId); + }, []); + + const goingTwice = useCallback((itemId: string) => { + getSocket().emit("auctioneer_going_twice", itemId); + }, []); + + const sold = useCallback((itemId: string) => { + getSocket().emit("auctioneer_sold", itemId); + }, []); + + const pass = useCallback((itemId: string) => { + getSocket().emit("auctioneer_pass", itemId); + }, []); + + /** Suggest next bid = current high bid + increment (or opening bid if no bids yet) */ + const suggestNextBid = useCallback((): number => { + const item = state.currentItem; + if (!item) return 0; + return item.currentHighBid != null + ? item.currentHighBid + item.bidIncrement + : item.openingBid; + }, [state.currentItem]); + + return { + ...state, + activateItem, + callNextBid, + acceptBid, + goingOnce, + goingTwice, + sold, + pass, + suggestNextBid, + }; +} diff --git a/packages/client/src/index.css b/packages/client/src/index.css index 942337e..3f36b2c 100644 --- a/packages/client/src/index.css +++ b/packages/client/src/index.css @@ -2,13 +2,106 @@ @tailwind components; @tailwind utilities; +/* ── CSS custom properties ─────────────────────────────────────────────────── */ +:root { + --color-brand: #2b5916; + --color-brand-dark: #1e3f10; + --color-gold: #c4952a; + --color-gold-light: #f4da99; + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-top: env(safe-area-inset-top, 0px); +} + +/* ── Base ───────────────────────────────────────────────────────────────────── */ @layer base { html { - /* Prevent text-size inflation on mobile */ -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + /* Prevents iOS rubber-band on the outer scroll */ + overscroll-behavior: none; } body { - @apply bg-white text-gray-900 antialiased; + @apply bg-gray-50 text-gray-900 antialiased; + font-feature-settings: "kern" 1, "liga" 1; + } + + /* Make all tap highlights match brand */ + * { + -webkit-tap-highlight-color: rgb(43 89 22 / 0.12); + } + + h1, h2, h3, h4, h5, h6 { + @apply font-bold tracking-tight; } } + +/* ── Component layer ────────────────────────────────────────────────────────── */ +@layer components { + /* Primary button */ + .btn-primary { + @apply inline-flex items-center justify-center gap-2 + rounded-xl px-5 py-3 + bg-brand-700 text-white font-semibold text-sm + shadow-bid-btn + hover:bg-brand-800 + active:scale-[0.97] + transition-all duration-150 + disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none; + } + + /* Ghost / secondary button */ + .btn-ghost { + @apply inline-flex items-center justify-center gap-2 + rounded-xl px-5 py-3 + border border-brand-200 bg-white text-brand-700 font-semibold text-sm + hover:bg-brand-50 + active:scale-[0.97] + transition-all duration-150 + disabled:opacity-40 disabled:cursor-not-allowed; + } + + /* Gold accent button */ + .btn-gold { + @apply inline-flex items-center justify-center gap-2 + rounded-xl px-5 py-3 + bg-gold-500 text-white font-semibold text-sm + hover:bg-gold-600 + active:scale-[0.97] + transition-all duration-150 + disabled:opacity-40 disabled:cursor-not-allowed; + } + + /* Card surface */ + .card { + @apply bg-white rounded-2xl shadow-card border border-gray-100; + } + + /* Form input */ + .field { + @apply w-full rounded-xl border border-gray-200 bg-white + px-4 py-3 text-sm text-gray-900 + placeholder:text-gray-400 + focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 + transition-colors; + } + + /* Subtle badge */ + .badge { + @apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold; + } + + /* Section header inside a page */ + .section-title { + @apply text-xs uppercase tracking-widest font-semibold text-gray-400; + } +} + +/* ── Bottom nav safe area ───────────────────────────────────────────────────── */ +.pb-safe { + padding-bottom: calc(4rem + var(--safe-bottom)); +} + +.bottom-nav { + padding-bottom: var(--safe-bottom); +} diff --git a/packages/client/src/pages/admin/BiddersPage.tsx b/packages/client/src/pages/admin/BiddersPage.tsx index 06ecc43..e9c02ea 100644 --- a/packages/client/src/pages/admin/BiddersPage.tsx +++ b/packages/client/src/pages/admin/BiddersPage.tsx @@ -1,22 +1,23 @@ /** - * Admin β†’ Bidders – profiles, paddles, QR codes, CSV import. + * Admin β†’ Bidders β€” profiles, paddles, QR codes, CSV import. * TODO: CRUD + bulk import via /api/bidders. */ export default function AdminBiddersPage() { return ( -
+
-

Bidder Manager

+
+

Bidders

+

Profiles, paddles, QR codes

+
- - + +
-
+
Bidder list β€” not yet implemented
-
+
); } diff --git a/packages/client/src/pages/admin/CheckoutPage.tsx b/packages/client/src/pages/admin/CheckoutPage.tsx index fa658c4..fba8aab 100644 --- a/packages/client/src/pages/admin/CheckoutPage.tsx +++ b/packages/client/src/pages/admin/CheckoutPage.tsx @@ -1,14 +1,23 @@ /** - * Admin β†’ Checkout – cashier station; find bidder, take payment, print receipt. + * Admin β†’ Checkout β€” cashier station; find bidder, take payment, print receipt. * TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture. */ export default function AdminCheckoutPage() { return ( -
-

Checkout

-
+
+
+

Checkout

+

Cashier station β€” search by paddle or name

+
+ +
Cashier station β€” not yet implemented
-
+
); } diff --git a/packages/client/src/pages/admin/DashboardPage.tsx b/packages/client/src/pages/admin/DashboardPage.tsx index b9e1828..7e16971 100644 --- a/packages/client/src/pages/admin/DashboardPage.tsx +++ b/packages/client/src/pages/admin/DashboardPage.tsx @@ -1,19 +1,41 @@ /** - * Admin dashboard – overview of events, recent bids, revenue snapshot. + * Admin dashboard β€” overview of events, recent bids, revenue snapshot. * TODO: fetch org summary from /api/reporting. */ export default function AdminDashboard() { + const stats = [ + { label: "Events", value: "β€”", icon: "πŸ—“οΈ" }, + { label: "Bidders", value: "β€”", icon: "🎟️" }, + { label: "Revenue", value: "β€”", icon: "πŸ’°" }, + ]; + return ( -
-

Admin Dashboard

+
+
+

Dashboard

+

Storybook Farm Auction Platform

+
+ + {/* Stat cards */}
- {["Events", "Bidders", "Revenue"].map((label) => ( -
-

{label}

-

β€”

+ {stats.map(({ label, value, icon }) => ( +
+
+ {icon} +

{label}

+
+

{value}

))}
-
+ + {/* Placeholder activity feed */} +
+

Recent Activity

+
+ Activity feed β€” not yet implemented +
+
+ ); } diff --git a/packages/client/src/pages/admin/EventsPage.tsx b/packages/client/src/pages/admin/EventsPage.tsx index 5606082..324d786 100644 --- a/packages/client/src/pages/admin/EventsPage.tsx +++ b/packages/client/src/pages/admin/EventsPage.tsx @@ -1,19 +1,22 @@ /** - * Admin β†’ Events – list, create, edit events. + * Admin β†’ Events β€” list, create, edit events. * TODO: CRUD via /api/events. */ export default function AdminEventsPage() { return ( -
+
-

Events

-
-
+
Events list β€” not yet implemented
-
+ ); } diff --git a/packages/client/src/pages/admin/FundANeedPage.tsx b/packages/client/src/pages/admin/FundANeedPage.tsx index 2d3668d..9c53405 100644 --- a/packages/client/src/pages/admin/FundANeedPage.tsx +++ b/packages/client/src/pages/admin/FundANeedPage.tsx @@ -1,14 +1,17 @@ /** - * Admin β†’ Fund-a-Need / Paddle Raise – set tiers, open campaign, show live total. + * Admin β†’ Fund-a-Need / Paddle Raise β€” set tiers, open campaign, show live total. * TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events. */ export default function FundANeedPage() { return ( -
-

Fund-a-Need

-
- Paddle raise setup & live totals β€” not yet implemented +
+
+

Fund-a-Need

+

Paddle raise setup & live totals

-
+
+ Paddle raise setup & live totals β€” not yet implemented +
+ ); } diff --git a/packages/client/src/pages/admin/ItemsPage.tsx b/packages/client/src/pages/admin/ItemsPage.tsx index 58e942e..4176b04 100644 --- a/packages/client/src/pages/admin/ItemsPage.tsx +++ b/packages/client/src/pages/admin/ItemsPage.tsx @@ -1,14 +1,22 @@ /** - * Admin β†’ Items – manage lots, categories, media, donor info, increments. + * Admin β†’ Items β€” manage lots, categories, media, donor info, increments. * TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart). */ export default function AdminItemsPage() { return ( -
-

Item Manager

-
- Item list & editor β€” not yet implemented +
+
+
+

Items

+

Lots, media, donor info, bid increments

+
+
-
+
+ Item list & editor β€” not yet implemented +
+ ); } diff --git a/packages/client/src/pages/admin/ReportingPage.tsx b/packages/client/src/pages/admin/ReportingPage.tsx index 9d03739..1749b08 100644 --- a/packages/client/src/pages/admin/ReportingPage.tsx +++ b/packages/client/src/pages/admin/ReportingPage.tsx @@ -1,14 +1,17 @@ /** - * Admin β†’ Reporting – revenue, sell-through, bidder activity, audit log. + * Admin β†’ Reporting β€” revenue, sell-through, bidder activity, audit log. * TODO: fetch /api/reporting/events/:id/*. */ export default function AdminReportingPage() { return ( -
-

Reporting

-
+
+
+

Reporting

+

Revenue, sell-through, audit log

+
+
Reports β€” not yet implemented
-
+ ); } diff --git a/packages/client/src/pages/auth/LoginPage.tsx b/packages/client/src/pages/auth/LoginPage.tsx index f2942f3..f7b6c19 100644 --- a/packages/client/src/pages/auth/LoginPage.tsx +++ b/packages/client/src/pages/auth/LoginPage.tsx @@ -1,18 +1,228 @@ /** - * Login – email magic link or SMS OTP entry point. - * TODO: implement magic-link request form and OTP flow. + * Login β€” email magic link or SMS OTP. + * + * Two tabs: Email and Phone. + * Email flow : POST /api/auth/magic-link β†’ success message + * Phone flow : POST /api/auth/otp/send β†’ OTP entry β†’ POST /api/auth/otp/verify β†’ JWT */ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api } from "../../lib/api.js"; +import { useAuthStore } from "../../store/auth.js"; +import type { Bidder } from "@storybid/shared"; + +type Tab = "email" | "phone"; +type PhasePhone = "entry" | "otp"; + export default function LoginPage() { + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + const [tab, setTab] = useState("email"); + + // Email state + const [email, setEmail] = useState(""); + const [emailSent, setEmailSent] = useState(false); + const [emailLoading, setEmailLoading] = useState(false); + const [emailError, setEmailError] = useState(""); + + // Phone state + const [phone, setPhone] = useState(""); + const [otp, setOtp] = useState(""); + const [phonePhase, setPhonePhase] = useState("entry"); + const [phoneLoading, setPhoneLoading] = useState(false); + const [phoneError, setPhoneError] = useState(""); + + // ── Email handlers ──────────────────────────────────────────────────────────── + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setEmailError(""); + setEmailLoading(true); + try { + await api.post("/api/auth/magic-link", { email }); + setEmailSent(true); + } catch { + setEmailError("Could not send link. Check the email address and try again."); + } finally { + setEmailLoading(false); + } + }; + + // ── Phone handlers ──────────────────────────────────────────────────────────── + + const handlePhoneSend = async (e: React.FormEvent) => { + e.preventDefault(); + setPhoneError(""); + setPhoneLoading(true); + const normalised = phone.startsWith("+") ? phone : `+1${phone.replace(/\D/g, "")}`; + try { + await api.post("/api/auth/otp/send", { phone: normalised }); + setPhone(normalised); + setPhonePhase("otp"); + } catch (err: unknown) { + setPhoneError( + err instanceof Error ? err.message : "Could not send code. Check your phone number.", + ); + } finally { + setPhoneLoading(false); + } + }; + + const handleOtpVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setPhoneError(""); + setPhoneLoading(true); + try { + const deviceId = localStorage.getItem("sb_device_id") ?? undefined; + const res = await api.post<{ token: string; bidder: Bidder; role: string }>( + "/api/auth/otp/verify", + { phone, code: otp, deviceId }, + ); + setAuth(res.token, res.bidder, res.role); + navigate("/"); + } catch { + setPhoneError("Incorrect or expired code. Please try again."); + } finally { + setPhoneLoading(false); + } + }; + return ( -
-
-

Sign in to bid

-

- Enter your email for a magic link, or your phone number for a one-time code. -

- {/* TODO: LoginForm component */} -
- LoginForm β€” not yet implemented +
+
+ + {/* ── Brand header ── */} +
+

+ STORYBOOK FARM +

+

Sign in to bid

+
+ + {/* ── Tabs ── */} +
+ {(["email", "phone"] as Tab[]).map((t) => ( + + ))} +
+ +
+ {/* ── Email tab ── */} + {tab === "email" && ( + emailSent ? ( +
+
πŸ“¬
+

Check your inbox

+

+ We sent a sign-in link to {email}. The link expires in 15 minutes. +

+ +
+ ) : ( +
void handleEmailSubmit(e)} className="space-y-4"> +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="field" + /> +
+ {emailError &&

{emailError}

} + +
+ ) + )} + + {/* ── Phone tab ── */} + {tab === "phone" && ( + phonePhase === "entry" ? ( +
void handlePhoneSend(e)} className="space-y-4"> +
+ + setPhone(e.target.value)} + placeholder="+1 (555) 000-0000" + className="field" + /> +

+ US numbers: enter 10 digits. International: include country code (+XX). +

+
+ {phoneError &&

{phoneError}

} + +
+ ) : ( +
void handleOtpVerify(e)} className="space-y-4"> +

+ We sent a 6-digit code to {phone} +

+
+ + setOtp(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + className="field text-center text-2xl font-bold tracking-[0.4em]" + /> +
+ {phoneError &&

{phoneError}

} + + +
+ ) + )}
diff --git a/packages/client/src/pages/auth/VerifyPage.tsx b/packages/client/src/pages/auth/VerifyPage.tsx index 7ca2844..b985d88 100644 --- a/packages/client/src/pages/auth/VerifyPage.tsx +++ b/packages/client/src/pages/auth/VerifyPage.tsx @@ -1,11 +1,87 @@ /** - * Verify – handles magic-link ?token= callback and OTP confirmation. - * TODO: read token from URL, call /api/auth/verify, redirect to /. + * Verify – handles magic-link ?token= callback. + * + * Called when the user clicks the sign-in link from their email. + * Reads ?token= from the URL, calls GET /api/auth/verify, stores the JWT, + * then redirects to / (or the originally requested page via ?next=). */ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useAuthStore } from "../../store/auth.js"; +import type { Bidder } from "@storybid/shared"; + +type Phase = "verifying" | "success" | "error"; + export default function VerifyPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + const [phase, setPhase] = useState("verifying"); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const next = searchParams.get("next") ?? "/"; + + if (!token) { + setPhase("error"); + setErrorMsg("No verification token found. Check the link in your email."); + return; + } + + void (async () => { + try { + const res = await fetch(`/api/auth/verify?token=${encodeURIComponent(token)}`); + const data = (await res.json()) as { token?: string; bidder?: Bidder; role?: string; error?: string }; + + if (!res.ok || !data.token) { + throw new Error(data.error ?? "Verification failed"); + } + + setAuth(data.token, data.bidder!, data.role ?? "bidder"); + setPhase("success"); + + // Brief success flash then redirect + setTimeout(() => navigate(next, { replace: true }), 800); + } catch (err) { + setPhase("error"); + setErrorMsg( + err instanceof Error ? err.message : "This link may have expired. Request a new one.", + ); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( -
-

Verifying…

+
+
+ {phase === "verifying" && ( + <> +
⏳
+

Verifying your link…

+ + )} + + {phase === "success" && ( + <> +
βœ…
+

Signed in! Redirecting…

+ + )} + + {phase === "error" && ( + <> +
❌
+

Sign-in failed

+

{errorMsg}

+ + Back to sign in + + + )} +
); } diff --git a/packages/client/src/pages/bidder/CheckoutPage.tsx b/packages/client/src/pages/bidder/CheckoutPage.tsx index b25ed10..3875882 100644 --- a/packages/client/src/pages/bidder/CheckoutPage.tsx +++ b/packages/client/src/pages/bidder/CheckoutPage.tsx @@ -1,14 +1,14 @@ /** - * Bidder checkout – shows won lots, total, and Stripe Payment Element. + * Bidder checkout β€” shows won lots, total, and Stripe Payment Element. * TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success. */ export default function CheckoutPage() { return ( -
-

Checkout

-
+
+

Checkout

+
Stripe checkout β€” not yet implemented
-
+
); } diff --git a/packages/client/src/pages/bidder/HomePage.tsx b/packages/client/src/pages/bidder/HomePage.tsx index 3370ead..6e913c1 100644 --- a/packages/client/src/pages/bidder/HomePage.tsx +++ b/packages/client/src/pages/bidder/HomePage.tsx @@ -1,27 +1,100 @@ /** - * Bidder home – event welcome screen, quick nav to Live / Silent / My Bids. - * TODO: fetch event details, show upcoming lots, paddle number, QR code. + * Bidder home β€” welcome screen, quick nav cards, event status strip. + * TODO: fetch active event details from /api/events/active. */ +import { Link } from "react-router-dom"; +import { useAuthStore, bidderName } from "../../store/auth.js"; + +// ── Quick-action cards ───────────────────────────────────────────────────────── +const QUICK_ACTIONS = [ + { + to: "/live", + label: "Live Auction", + sub: "Bid in real time", + bg: "bg-brand-700", + text: "text-white", + sub_text: "text-brand-200", + icon: "πŸŽ™", + }, + { + to: "/silent", + label: "Silent Auction", + sub: "Browse & place bids", + bg: "bg-gold-500", + text: "text-white", + sub_text: "text-gold-100", + icon: "🏷️", + }, + { + to: "/my-bids", + label: "My Bids", + sub: "Track your activity", + bg: "bg-white", + text: "text-brand-800", + sub_text: "text-gray-400", + icon: "πŸ“‹", + border: true, + }, + { + to: "/checkout", + label: "Checkout", + sub: "Pay for won items", + bg: "bg-white", + text: "text-brand-800", + sub_text: "text-gray-400", + icon: "πŸ’³", + border: true, + }, +] as const; + export default function HomePage() { + const bidder = useAuthStore((s) => s.bidder); + return ( -
-

Welcome to the Auction

- -
+
+ {/* ── Hero strip ── */} +
+

+ Welcome back +

+

+ {bidderName(bidder)} +

+ {bidder?.paddleNumber && ( +
+ #{bidder.paddleNumber} + Your paddle number +
+ )} +
+ + {/* ── Cards grid (overlaps hero by 1rem) ── */} +
+ {/* Event status card */} +
+ 🌿 +
+

Storybook Farm Charity Gala

+

Auction is live β€” good luck!

+
+ +
+ + {/* Quick-action grid */} +
+ {QUICK_ACTIONS.map(({ to, label, sub, bg, text, sub_text, icon, border }) => ( + + {icon} +

{label}

+

{sub}

+ + ))} +
+
+
); } diff --git a/packages/client/src/pages/bidder/ItemPage.tsx b/packages/client/src/pages/bidder/ItemPage.tsx index c7248ee..d4089cd 100644 --- a/packages/client/src/pages/bidder/ItemPage.tsx +++ b/packages/client/src/pages/bidder/ItemPage.tsx @@ -7,12 +7,17 @@ * - Media carousel (images, video embed, documents) * - Place bid form with offline-outbox fallback via db.outbox */ +import { useParams } from "react-router-dom"; + export default function ItemPage() { + const { id } = useParams<{ id: string }>(); + return ( -
-
+
+

Item #{id}

+
Item detail β€” not yet implemented
-
+
); } diff --git a/packages/client/src/pages/bidder/LivePage.tsx b/packages/client/src/pages/bidder/LivePage.tsx index 1297b05..7480128 100644 --- a/packages/client/src/pages/bidder/LivePage.tsx +++ b/packages/client/src/pages/bidder/LivePage.tsx @@ -9,17 +9,16 @@ import { useParams } from "react-router-dom"; import { useLiveAuction } from "../../hooks/useLiveAuction.js"; import { useOfflineBids } from "../../hooks/useOfflineBids.js"; -const STATE_LABELS: Record = { - preview: "Up next", - active: "Bidding open", - going_once: "Going once…", - going_twice: "Going twice…", - sold: "SOLD", - passed: "Passed", +const STATE_META: Record = { + preview: { label: "Up next", color: "bg-brand-100 text-brand-700" }, + active: { label: "Bidding open", color: "bg-emerald-100 text-emerald-700" }, + going_once: { label: "Going once…", color: "bg-gold-100 text-gold-700" }, + going_twice: { label: "Going twice…", color: "bg-orange-100 text-orange-700" }, + sold: { label: "SOLD", color: "bg-gray-100 text-gray-500" }, + passed: { label: "Passed", color: "bg-gray-100 text-gray-400" }, }; export default function LivePage() { - // eventId comes from route or a global store; use param or fallback const { eventId = "" } = useParams<{ eventId?: string }>(); const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } = useLiveAuction(eventId); @@ -32,66 +31,73 @@ export default function LivePage() { placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq); }; - const isSold = state === "sold" || state === "passed"; const canBid = state === "active" || state === "going_once" || state === "going_twice"; + const meta = state ? (STATE_META[state] ?? { label: state, color: "bg-gray-100 text-gray-500" }) : null; return ( -
- {/* Status banner */} -
-

- Live Auction -

- {state && ( - - {STATE_LABELS[state] ?? state} - - )} -
+
+ {/* ── Section header ── */} +

Live Auction

{currentItem ? ( <> + {/* State badge */} + {meta && ( +
+ + {meta.label} + +
+ )} + {/* Item info */} -
-

Lot {currentItem.lotNumber}

-

{currentItem.title}

+
+

+ Lot {currentItem.lotNumber} +

+

{currentItem.title}

{currentItem.donorName && ( -

Donated by {currentItem.donorName}

+

Donated by {currentItem.donorName}

)}
- {/* Current bid */} + {/* Current bid display */}
-

Current bid

-

+

+ Current bid +

+

{currentBid != null ? `$${currentBid.toLocaleString()}` : "β€”"}

- {/* Called amount + bid button */} + {/* Bid button */} {calledAmount != null && ( )} - {/* Recent bids stream */} + {/* Recent bids */} {recentBids.length > 0 && (
-

Recent bids

+

Recent bids

    {recentBids.map((b) => ( -
  • - {b.createdAt} - ${Number(b.amount).toLocaleString()} +
  • + {b.createdAt} + + ${Number(b.amount).toLocaleString()} +
  • ))}
@@ -99,10 +105,13 @@ export default function LivePage() { )} ) : ( -
-

Waiting for the auctioneer to open a lot…

+
+ πŸŽ™ +

+ Waiting for the auctioneer to open a lot… +

)} -
+ ); } diff --git a/packages/client/src/pages/bidder/MyBidsPage.tsx b/packages/client/src/pages/bidder/MyBidsPage.tsx index 1a70944..e5e56e5 100644 --- a/packages/client/src/pages/bidder/MyBidsPage.tsx +++ b/packages/client/src/pages/bidder/MyBidsPage.tsx @@ -4,11 +4,11 @@ */ export default function MyBidsPage() { return ( -
-

My Bids

-
+
+

My Bids

+
Bid history β€” not yet implemented
-
+ ); } diff --git a/packages/client/src/pages/bidder/ProfilePage.tsx b/packages/client/src/pages/bidder/ProfilePage.tsx index 37d33a3..5e71058 100644 --- a/packages/client/src/pages/bidder/ProfilePage.tsx +++ b/packages/client/src/pages/bidder/ProfilePage.tsx @@ -1,14 +1,45 @@ /** - * Bidder profile – paddle number, contact info, digital paddle QR, notifications prefs. + * Bidder profile β€” paddle number, contact info, digital paddle QR, notification prefs. * TODO: fetch /api/bidders/me, render paddle QR code. */ +import { useAuthStore, bidderName } from "../../store/auth.js"; + export default function ProfilePage() { + const bidder = useAuthStore((s) => s.bidder); + const logout = useAuthStore((s) => s.logout); + return ( -
-

Profile

-
- Profile & digital paddle β€” not yet implemented +
+ {/* Profile header */} + {bidder && ( +
+
+ {bidder.firstName.charAt(0).toUpperCase()} +
+
+

{bidderName(bidder)}

+

{bidder.email ?? bidder.phone}

+ {bidder.paddleNumber && ( +

+ Paddle #{bidder.paddleNumber} +

+ )} +
+
+ )} + + {/* Digital paddle placeholder */} +
+ Digital paddle QR code β€” not yet implemented
-
+ + {/* Sign out */} + + ); } diff --git a/packages/client/src/pages/bidder/SilentPage.tsx b/packages/client/src/pages/bidder/SilentPage.tsx index 08489db..f503cf9 100644 --- a/packages/client/src/pages/bidder/SilentPage.tsx +++ b/packages/client/src/pages/bidder/SilentPage.tsx @@ -27,17 +27,18 @@ export default function SilentPage({ eventId, auctionId }: Props) { if (!items.length) { return ( -
-

Silent Auction

-

Loading items…

-
+
+ 🏷️ +

Loading silent auction items…

+
); } return ( -
-

Silent Auction

-
    +
    +

    Silent Auction

    + +
      {items.map((item) => { const isOutbid = outbidItemIds.has(item.id); const isClosed = item.state === "closed" || item.state === "passed"; @@ -48,49 +49,44 @@ export default function SilentPage({ eventId, auctionId }: Props) { return (
    • {/* Outbid banner */} {isOutbid && ( -
      - You've been outbid! +
      + ⚑ You've been outbid!
      )} -
      -
      -

      Lot {item.lotNumber}

      - +
      +
      +

      Lot {item.lotNumber}

      + {isClosed ? "Closed" : "Open"}
      - + {item.title} -
      +
      -

      Current bid

      -

      +

      Current bid

      +

      {item.currentHighBid != null ? `$${item.currentHighBid.toLocaleString()}` - : `Starting at $${item.openingBid.toLocaleString()}`} + : `$${item.openingBid.toLocaleString()}`}

      {!isClosed && ( @@ -101,6 +97,6 @@ export default function SilentPage({ eventId, auctionId }: Props) { ); })}
    -
+ ); } diff --git a/packages/client/src/pages/staff/AuctioneerPage.tsx b/packages/client/src/pages/staff/AuctioneerPage.tsx index d1b6852..1483603 100644 --- a/packages/client/src/pages/staff/AuctioneerPage.tsx +++ b/packages/client/src/pages/staff/AuctioneerPage.tsx @@ -1,20 +1,256 @@ /** - * Auctioneer console – optimised for tablet in landscape. - * Shows: current lot, current bid, next callable bid, recent bid stream, - * and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass. + * Auctioneer console – optimised for tablet landscape. * - * TODO: - * - Subscribe to all live auction socket events - * - Emit auctioneer_* events on button press - * - Display large-format current bid and paddle number + * Left panel : item list (all lots with state badges) + * Right panel: active lot controls + * - Current bid display + * - Called amount adjustment + * - Going Once / Going Twice / Sold / Pass + * - Recent bid stream */ +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useAuctioneerControls } from "../../hooks/useAuctioneerControls.js"; +import type { ItemState } from "@storybid/shared"; + +const STATE_COLOR: Record = { + preview: "bg-gray-100 text-gray-500", + active: "bg-green-100 text-green-700", + going_once: "bg-yellow-100 text-yellow-700", + going_twice: "bg-orange-100 text-orange-700", + sold: "bg-blue-100 text-blue-700", + passed: "bg-red-100 text-red-500", + closed: "bg-gray-100 text-gray-400", +}; + +const STATE_LABEL: Record = { + preview: "Preview", + active: "Active", + going_once: "Going Once", + going_twice: "Going Twice", + sold: "Sold", + passed: "Passed", + closed: "Closed", +}; + export default function AuctioneerPage() { + const [searchParams] = useSearchParams(); + const eventId = searchParams.get("eventId") ?? ""; + const auctionId = searchParams.get("auctionId") ?? ""; + + const { + items, currentItem, currentBid, calledAmount, state, recentBids, loading, + activateItem, callNextBid, goingOnce, goingTwice, sold, pass, suggestNextBid, + } = useAuctioneerControls(eventId, auctionId); + + const [customAmount, setCustomAmount] = useState(""); + + const handleCallAmount = () => { + if (!currentItem) return; + const amt = customAmount ? parseInt(customAmount, 10) : suggestNextBid(); + if (!isNaN(amt) && amt > 0) { + callNextBid(currentItem.id, amt); + setCustomAmount(""); + } + }; + + const isClosed = state === "sold" || state === "passed"; + return ( -
-

Auctioneer Console

-
- Live auction controls β€” not yet implemented +
+ {/* Header */} +
+

Auctioneer Console

+
+ {state && ( + + {STATE_LABEL[state]} + + )} + {items.length} lots +
+
+ +
+ {/* ── Item list (left) ─────────────────────────────────────────────── */} + + + {/* ── Active lot controls (right) ──────────────────────────────────── */} +
+ {!currentItem ? ( +
+

+ Select a lot from the list to activate it +

+
+ ) : ( + <> + {/* Item info */} +
+

Lot {currentItem.lotNumber}

+

{currentItem.title}

+ {currentItem.donorName && ( +

Donated by {currentItem.donorName}

+ )} +

+ Opening ${currentItem.openingBid.toLocaleString()} Β· + Increment ${currentItem.bidIncrement.toLocaleString()} + {currentItem.reservePrice != null && + ` Β· Reserve $${currentItem.reservePrice.toLocaleString()}`} +

+
+ + {/* Current bid */} +
+
+

+ Current Bid +

+

+ {currentBid != null ? `$${currentBid.toLocaleString()}` : "β€”"} +

+
+
+

+ Called Amount +

+

+ {calledAmount != null ? `$${calledAmount.toLocaleString()}` : "β€”"} +

+
+
+ + {/* Call next bid */} + {!isClosed && ( +
+

Call next bid

+
+ + setCustomAmount(e.target.value)} + className="w-32 bg-gray-800 border border-gray-700 rounded-lg px-3 text-white placeholder-gray-600 focus:outline-none focus:border-brand-500" + /> + +
+
+ )} + + {/* State controls */} + {!isClosed && ( +
+ + + + +
+ )} + + {/* Recent bids */} + {recentBids.length > 0 && ( +
+

+ Bid stream +

+
    + {recentBids.map((b, i) => ( +
  • + + {new Date(b.createdAt).toLocaleTimeString()} + + ${Number(b.amount).toLocaleString()} +
  • + ))} +
+
+ )} + + )} +
-
+ ); } diff --git a/packages/client/src/pages/staff/CheckInPage.tsx b/packages/client/src/pages/staff/CheckInPage.tsx index 14be9b5..1c2b4ef 100644 --- a/packages/client/src/pages/staff/CheckInPage.tsx +++ b/packages/client/src/pages/staff/CheckInPage.tsx @@ -1,5 +1,5 @@ /** - * Check-in station – search bidders, scan QR, assign paddle, confirm payment readiness. + * Check-in station β€” search bidders, scan QR, assign paddle, confirm payment readiness. * * TODO: * - Search /api/bidders?eventId=&q= @@ -9,10 +9,24 @@ */ export default function CheckInPage() { return ( -
-

Check-In

-
- QR scan & bidder search β€” not yet implemented +
+
+ βœ… +
+

Check-In

+

Scan QR or search bidder name / paddle

+
+
+ + + +
+ QR scan & bidder search β€” not yet implemented
); diff --git a/packages/client/src/pages/staff/DisplayBoardPage.tsx b/packages/client/src/pages/staff/DisplayBoardPage.tsx index 457f763..1d92257 100644 --- a/packages/client/src/pages/staff/DisplayBoardPage.tsx +++ b/packages/client/src/pages/staff/DisplayBoardPage.tsx @@ -1,26 +1,184 @@ /** * Display board – read-only fullscreen view for projector / TV. - * Shows: current item, current bid, bidder paddle, org branding, - * and optionally a fundraising thermometer. * - * TODO: - * - Subscribe to live auction events (read-only socket connection) - * - Fullscreen CSS layout with large typography - * - Paddle raise thermometer via paddle_raise_update events + * Shows: current item, current bid, bidder paddle, state banner, and an + * optional Fund-a-Need thermometer when a campaign is active. + * No login required β€” connects to the socket as a guest viewer. */ +import { useState, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { getSocket } from "../../lib/socket.js"; +import type { AuctionItem, ItemState } from "@storybid/shared"; + +const STATE_BG: Partial> = { + going_once: "bg-yellow-500", + going_twice: "bg-orange-500", + sold: "bg-green-600", + passed: "bg-gray-600", +}; + +const STATE_LABEL: Partial> = { + going_once: "GOING ONCE", + going_twice: "GOING TWICE", + sold: "SOLD!", + passed: "PASSED", +}; + export default function DisplayBoardPage() { + const [searchParams] = useSearchParams(); + const eventId = searchParams.get("eventId") ?? ""; + + const [item, setItem] = useState(null); + const [currentBid, setCurrentBid] = useState(null); + const [calledAmount, setCalledAmount] = useState(null); + const [itemState, setItemState] = useState(null); + const [campaign, setCampaign] = useState<{ + name: string; + goal: number | null; + totalRaised: number; + } | null>(null); + + useEffect(() => { + const socket = getSocket(); // no auth token β€” guest connection + if (eventId) socket.emit("join_event", eventId); + + socket.on("item_activated", ({ item: i }) => { + setItem(i); + setCurrentBid(i.currentHighBid); + setCalledAmount(i.openingBid); + setItemState(i.state); + }); + + socket.on("next_live_bid", ({ amount }) => setCalledAmount(amount)); + + socket.on("live_bid_accepted", ({ item: i }) => { + setCurrentBid(i.currentHighBid); + setItemState(i.state); + setItem(i); + }); + + socket.on("item_state_changed", ({ state }) => setItemState(state)); + + socket.on("item_sold", ({ amount }) => { + setCurrentBid(amount); + setItemState("sold"); + }); + + socket.on("paddle_raise_update", ({ campaignId: _id, totalRaised }) => { + setCampaign((c) => (c ? { ...c, totalRaised } : null)); + }); + + return () => { + if (eventId) socket.emit("leave_event", eventId); + socket.off("item_activated"); + socket.off("next_live_bid"); + socket.off("live_bid_accepted"); + socket.off("item_state_changed"); + socket.off("item_sold"); + socket.off("paddle_raise_update"); + }; + }, [eventId]); + + const stateBanner = itemState ? STATE_LABEL[itemState] : null; + const stateBg = itemState ? (STATE_BG[itemState] ?? "") : ""; + return ( -
-

Storybid

-
-

Current Lot

-

β€”

+
+ {/* State banner */} + {stateBanner && ( +
+ {stateBanner} +
+ )} + + {/* Main content */} +
+ {/* Org / event branding */} +

+ Live Auction +

+ + {item ? ( + <> + {/* Lot + title */} +
+

Lot {item.lotNumber}

+

+ {item.title} +

+ {item.donorName && ( +

Donated by {item.donorName}

+ )} +
+ + {/* Bid display */} +
+
+

+ Current Bid +

+

+ {currentBid != null ? `$${currentBid.toLocaleString()}` : "β€”"} +

+
+ {calledAmount != null && calledAmount !== currentBid && ( +
+

+ Next Bid +

+

+ ${calledAmount.toLocaleString()} +

+
+ )} +
+ + {/* Fair market value */} + {item.fairMarketValue != null && ( +

+ Fair market value: ${item.fairMarketValue.toLocaleString()} +

+ )} + + ) : ( +
+

Storybid

+

Auction beginning soon…

+
+ )}
-
-

Current Bid

-

$β€”

-

Paddle β€”

-
-
+ + {/* Fund-a-Need thermometer */} + {campaign && ( +
+
+ {campaign.name} + + ${campaign.totalRaised.toLocaleString()} + {campaign.goal && ( + + {" "}/ ${campaign.goal.toLocaleString()} + + )} + +
+ {campaign.goal && ( +
+
+
+ )} +
+ )} +
); } diff --git a/packages/client/src/pages/staff/SpotterPage.tsx b/packages/client/src/pages/staff/SpotterPage.tsx index 80e459f..61a5cab 100644 --- a/packages/client/src/pages/staff/SpotterPage.tsx +++ b/packages/client/src/pages/staff/SpotterPage.tsx @@ -1,19 +1,170 @@ /** * Spotter mode – floor volunteer enters bids by paddle number. - * Simple: paddle number input + confirm button. Emits auctioneer_accept_bid. * - * TODO: - * - Show current item and called amount (read-only) - * - Large paddle number input with numeric keyboard - * - Emit place_live_bid (spotter path) on confirm + * Shows: current item title + called amount (read-only, from socket) + * Input: large paddle number field + confirm button + * Emits: auctioneer_accept_bid with the paddle's bidderId resolved server-side + * + * Note: the spotter does NOT resolve paddle β†’ bidderId in the browser. + * Instead we emit auctioneer_accept_bid with a `paddleNumber` hint and let + * the server look up the bidderId from the enrollment β€” safer, less data in client. + * For now we emit place_live_bid which the server validates against the item state. */ +import { useState, useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; +import { getSocket } from "../../lib/socket.js"; +import type { AuctionItem, ItemState } from "@storybid/shared"; + +const STATE_LABEL: Partial> = { + active: "Bidding open", + going_once: "Going once…", + going_twice: "Going twice…", +}; + export default function SpotterPage() { + const [searchParams] = useSearchParams(); + const eventId = searchParams.get("eventId") ?? ""; + + const [currentItem, setCurrentItem] = useState(null); + const [calledAmount, setCalledAmount] = useState(null); + const [itemState, setItemState] = useState(null); + const [paddle, setPaddle] = useState(""); + const [lastBid, setLastBid] = useState<{ paddle: string; amount: number } | null>(null); + const [flash, setFlash] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + const socket = getSocket(); + socket.emit("join_event", eventId); + + socket.on("item_activated", ({ item }) => { + setCurrentItem(item); + setCalledAmount(item.openingBid); + setItemState(item.state); + setLastBid(null); + setPaddle(""); + setTimeout(() => inputRef.current?.focus(), 100); + }); + + socket.on("next_live_bid", ({ amount }) => setCalledAmount(amount)); + + socket.on("live_bid_accepted", ({ item }) => { + setItemState(item.state); + setCurrentItem(item); + }); + + socket.on("item_state_changed", ({ state }) => setItemState(state)); + + socket.on("item_sold", () => { + setItemState("sold"); + setPaddle(""); + }); + + return () => { + socket.emit("leave_event", eventId); + socket.off("item_activated"); + socket.off("next_live_bid"); + socket.off("live_bid_accepted"); + socket.off("item_state_changed"); + socket.off("item_sold"); + }; + }, [eventId]); + + const canBid = + currentItem !== null && + calledAmount !== null && + (itemState === "active" || itemState === "going_once" || itemState === "going_twice"); + + const handleSubmit = () => { + if (!canBid || !paddle.trim()) return; + + // Spotter emits accept_bid with paddle number in the bidderId field. + // The server resolves it via BidderEventEnrollment.paddleNumber. + // We use the socket id as a stand-in deviceId for spotter bids. + const socket = getSocket(); + socket.emit("auctioneer_accept_bid", { + itemId: currentItem!.id, + bidderId: `paddle:${paddle.trim()}`, // server resolves paddle β†’ bidderId + amount: calledAmount!, + }); + + setLastBid({ paddle: paddle.trim(), amount: calledAmount! }); + setPaddle(""); + setFlash(true); + setTimeout(() => setFlash(false), 600); + setTimeout(() => inputRef.current?.focus(), 100); + }; + return ( -
-

Spotter

-
- Paddle entry β€” not yet implemented +
+ {/* Header */} +
+

Spotter

+ {itemState && STATE_LABEL[itemState] && ( + + {STATE_LABEL[itemState]} + + )} +
+ + {/* Current lot info */} +
+ {currentItem ? ( + <> +

+ Lot {currentItem.lotNumber} +

+

{currentItem.title}

+ + ) : ( +

Waiting for lot to be activated…

+ )}
+ + {/* Called amount */} +
+

Called amount

+

+ {calledAmount != null ? `$${calledAmount.toLocaleString()}` : "β€”"} +

+
+ + {/* Paddle input */} +
+ + setPaddle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + placeholder="000" + className="w-full text-center text-5xl font-black border-2 border-gray-200 rounded-2xl py-5 focus:outline-none focus:border-brand-500 tracking-widest" + /> + +
+ + {/* Last confirmed bid */} + {lastBid && ( +
+ Last: Paddle {lastBid.paddle} + {" Β· "} + ${lastBid.amount.toLocaleString()} +
+ )}
); } diff --git a/packages/client/src/store/auth.ts b/packages/client/src/store/auth.ts index 52d2dc4..d78436f 100644 --- a/packages/client/src/store/auth.ts +++ b/packages/client/src/store/auth.ts @@ -7,6 +7,8 @@ interface AuthState { role: string | null; setAuth: (token: string, bidder: Bidder, role: string) => void; clearAuth: () => void; + /** Alias for clearAuth β€” used by UI components. */ + logout: () => void; } export const useAuthStore = create((set) => ({ @@ -21,4 +23,14 @@ export const useAuthStore = create((set) => ({ localStorage.removeItem("sb_token"); set({ token: null, bidder: null, role: null }); }, + logout() { + localStorage.removeItem("sb_token"); + set({ token: null, bidder: null, role: null }); + }, })); + +/** Helper to get bidder display name from firstName + lastName. */ +export function bidderName(bidder: Bidder | null): string { + if (!bidder) return "Guest"; + return `${bidder.firstName} ${bidder.lastName}`.trim() || bidder.email || "Guest"; +} diff --git a/packages/client/tailwind.config.ts b/packages/client/tailwind.config.ts index cc25822..1e4b8f2 100644 --- a/packages/client/tailwind.config.ts +++ b/packages/client/tailwind.config.ts @@ -1,18 +1,78 @@ import type { Config } from "tailwindcss"; +// ── Storybook Farm brand palette ─────────────────────────────────────────────── +// Derived directly from the organization logo: +// Forest green β†’ "STORYBOOK FARM" text β‰ˆ #2B5016 +// Warm gold β†’ curved motto text β‰ˆ #C4952A + +const green = { + 50: "#f1f8ec", + 100: "#daefd0", + 200: "#b5dfa1", + 300: "#8bcb6d", + 400: "#63b43e", + 500: "#4a9528", + 600: "#3a771f", + 700: "#2b5916", // ← primary brand green (logo match) + 800: "#1e3f10", + 900: "#12270a", + 950: "#091406", +}; + +const gold = { + 50: "#fdf8ed", + 100: "#faeed0", + 200: "#f4da99", + 300: "#ecc45e", + 400: "#e4ae32", + 500: "#c4952a", // ← primary brand gold (logo match) + 600: "#a37820", + 700: "#815d19", + 800: "#614513", + 900: "#422e0e", + 950: "#221708", +}; + export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { extend: { colors: { - brand: { - 50: "#eff6ff", - 100: "#dbeafe", - 500: "#3b82f6", - 600: "#2563eb", - 700: "#1d4ed8", - 900: "#1e3a8a", - }, + brand: green, + gold, + }, + fontFamily: { + sans: [ + "Inter", + "ui-sans-serif", + "system-ui", + "-apple-system", + "BlinkMacSystemFont", + "Segoe UI", + "sans-serif", + ], + }, + borderRadius: { + "2xl": "1rem", + "3xl": "1.5rem", + }, + boxShadow: { + card: "0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.06)", + "card-lg": "0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06)", + "bid-btn": "0 4px 16px 0 rgb(43 89 22 / 0.35)", + }, + screens: { + xs: "390px", + }, + keyframes: { + "fade-in": { from: { opacity: "0", transform: "translateY(6px)" }, to: { opacity: "1", transform: "translateY(0)" } }, + "slide-up": { from: { opacity: "0", transform: "translateY(20px)" }, to: { opacity: "1", transform: "translateY(0)" } }, + "pulse-green": { "0%, 100%": { backgroundColor: "rgb(43 89 22 / 0.1)" }, "50%": { backgroundColor: "rgb(43 89 22 / 0.25)" } }, + }, + animation: { + "fade-in": "fade-in 0.2s ease-out", + "slide-up": "slide-up 0.3s ease-out", + "pulse-green": "pulse-green 1.5s ease-in-out infinite", }, }, }, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fb16d6f..69a854c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,7 @@ import type { import { app } from "./app.js"; import { registerSocketHandlers } from "./socket/index.js"; +import { startScheduler } from "./services/scheduler.js"; import { prisma } from "./lib/prisma.js"; const PORT = parseInt(process.env["PORT"] ?? "3001", 10); @@ -31,6 +32,7 @@ export const io = new Server< }); registerSocketHandlers(io); +startScheduler(io); httpServer.listen(PORT, () => { console.log(`[server] listening on http://localhost:${PORT}`); diff --git a/packages/server/src/routes/checkout.ts b/packages/server/src/routes/checkout.ts index e6befeb..d540c91 100644 --- a/packages/server/src/routes/checkout.ts +++ b/packages/server/src/routes/checkout.ts @@ -1,31 +1,256 @@ /** - * GET /api/checkout/:bidderId – get invoice for bidder - * POST /api/checkout/:bidderId/pay – create Stripe Payment Intent - * POST /api/checkout/:bidderId/capture – capture/finalize payment - * POST /api/checkout/donate – one-time donation - * POST /api/checkout/paddle-raise – paddle raise donation + * GET /api/checkout/:bidderId – get or create invoice for a bidder + event + * POST /api/checkout/:bidderId/intent – create Stripe Payment Intent, return client_secret + * POST /api/checkout/:bidderId/complete – mark invoice paid after webhook confirms + * POST /api/checkout/donate – one-time donation Payment Intent + * POST /api/checkout/paddle-raise – paddle raise donation Payment Intent */ import { Router } from "express"; +import { z } from "zod"; +import Stripe from "stripe"; +import { prisma } from "../lib/prisma.js"; import { requireAuth, requireRole } from "../middleware/auth.js"; export const checkoutRouter = Router(); -checkoutRouter.get("/:bidderId", requireAuth, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +function getStripe(): Stripe { + const key = process.env["STRIPE_SECRET_KEY"]; + if (!key) throw new Error("STRIPE_SECRET_KEY is not configured"); + return new Stripe(key, { apiVersion: "2024-04-10" }); +} + +// ── Get / create invoice ─────────────────────────────────────────────────────── + +checkoutRouter.get("/:bidderId", requireAuth, async (req, res) => { + const { bidderId } = req.params; + const { eventId } = req.query; + + const isOwn = req.auth!.sub === bidderId; + const isStaff = ["admin", "event_manager"].includes(req.auth!.role); + if (!isOwn && !isStaff) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + if (typeof eventId !== "string") { + res.status(400).json({ error: "eventId query param required" }); + return; + } + + // Find existing open invoice or build a new draft from winning bids + let invoice = await prisma.invoice.findFirst({ + where: { bidderId, eventId, status: { notIn: ["void"] } }, + include: { payments: true }, + }); + + if (!invoice) { + // Tally all items where this bidder is the current high bidder + item is sold/closed + const wonItems = await prisma.auctionItem.findMany({ + where: { + currentHighBidderId: bidderId, + state: { in: ["sold", "closed"] }, + auction: { eventId }, + }, + select: { id: true, title: true, currentHighBid: true }, + }); + + const totalAmount = wonItems.reduce( + (sum, i) => sum + Number(i.currentHighBid ?? 0), + 0, + ); + + invoice = await prisma.invoice.create({ + data: { + bidderId, + eventId, + totalAmount, + status: totalAmount > 0 ? "open" : "draft", + }, + include: { payments: true }, + }); + } + + // Attach won item summary + const wonItems = await prisma.auctionItem.findMany({ + where: { + currentHighBidderId: bidderId, + state: { in: ["sold", "closed"] }, + auction: { eventId }, + }, + select: { id: true, title: true, lotNumber: true, currentHighBid: true }, + }); + + res.json({ invoice, wonItems }); }); -checkoutRouter.post("/:bidderId/pay", requireAuth, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Create Payment Intent ────────────────────────────────────────────────────── + +const IntentSchema = z.object({ eventId: z.string() }); + +checkoutRouter.post("/:bidderId/intent", requireAuth, async (req, res) => { + const { bidderId } = req.params; + + const isOwn = req.auth!.sub === bidderId; + const isStaff = ["admin", "event_manager"].includes(req.auth!.role); + if (!isOwn && !isStaff) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const parse = IntentSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + + const invoice = await prisma.invoice.findFirst({ + where: { bidderId, eventId: parse.data.eventId, status: "open" }, + }); + + if (!invoice) { + res.status(404).json({ error: "No open invoice found" }); + return; + } + + if (Number(invoice.totalAmount) <= 0) { + res.status(400).json({ error: "Invoice total is zero" }); + return; + } + + try { + const stripe = getStripe(); + + // Amount in cents + const amountCents = Math.round(Number(invoice.totalAmount) * 100); + + const bidder = await prisma.bidder.findUniqueOrThrow({ + where: { id: bidderId }, + }); + + const intent = await stripe.paymentIntents.create({ + amount: amountCents, + currency: "usd", + metadata: { + invoiceId: invoice.id, + bidderId, + eventId: parse.data.eventId, + }, + description: `Auction invoice ${invoice.id}`, + receipt_email: bidder.email ?? undefined, + }); + + // Persist the Payment Intent ID on the invoice + await prisma.invoice.update({ + where: { id: invoice.id }, + data: { stripeInvoiceId: intent.id }, + }); + + // Create a pending payment record + await prisma.payment.create({ + data: { + invoiceId: invoice.id, + stripePaymentIntentId: intent.id, + amount: invoice.totalAmount, + currency: "usd", + status: "pending", + }, + }); + + res.json({ clientSecret: intent.client_secret, invoiceId: invoice.id }); + } catch (err) { + console.error("[checkout] Stripe error", err); + res.status(502).json({ error: "Payment provider error. Please try again." }); + } }); -checkoutRouter.post("/:bidderId/capture", requireAuth, requireRole("admin", "event_manager"), (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Donation ─────────────────────────────────────────────────────────────────── + +const DonateSchema = z.object({ + eventId: z.string(), + amount: z.number().positive(), + campaignId: z.string().optional(), + anonymous: z.boolean().default(false), }); -checkoutRouter.post("/donate", requireAuth, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +checkoutRouter.post("/donate", requireAuth, async (req, res) => { + const parse = DonateSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + + const { eventId, amount, campaignId, anonymous } = parse.data; + const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null; + + try { + const stripe = getStripe(); + const amountCents = Math.round(amount * 100); + + const intent = await stripe.paymentIntents.create({ + amount: amountCents, + currency: "usd", + metadata: { eventId, bidderId: bidderId ?? "guest", campaignId: campaignId ?? "" }, + description: campaignId ? `Paddle raise donation` : `General donation`, + }); + + // Stage donation record β€” confirmed by webhook + await prisma.donation.create({ + data: { + eventId, + bidderId, + campaignId: campaignId ?? null, + amount, + anonymous, + stripePaymentIntentId: intent.id, + }, + }); + + res.json({ clientSecret: intent.client_secret }); + } catch (err) { + console.error("[checkout] donation Stripe error", err); + res.status(502).json({ error: "Payment provider error. Please try again." }); + } }); -checkoutRouter.post("/paddle-raise", requireAuth, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Paddle Raise ─────────────────────────────────────────────────────────────── +// Paddle raise uses the same donation flow but we update the campaign total on success. +// Reuse /donate with a campaignId β€” the webhook handles incrementing totalRaised. + +checkoutRouter.post("/paddle-raise", requireAuth, async (req, res) => { + // Alias to /donate β€” campaignId required + const parse = DonateSchema.extend({ campaignId: z.string() }).safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + req.body = parse.data; + // Forward to donate handler logic inline + const { eventId, amount, campaignId, anonymous } = parse.data; + const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null; + + try { + const stripe = getStripe(); + const intent = await stripe.paymentIntents.create({ + amount: Math.round(amount * 100), + currency: "usd", + metadata: { eventId, bidderId: bidderId ?? "guest", campaignId }, + description: "Paddle raise donation", + }); + + await prisma.donation.create({ + data: { + eventId, + bidderId, + campaignId, + amount, + anonymous, + stripePaymentIntentId: intent.id, + }, + }); + + res.json({ clientSecret: intent.client_secret }); + } catch (err) { + console.error("[checkout] paddle raise Stripe error", err); + res.status(502).json({ error: "Payment provider error. Please try again." }); + } }); diff --git a/packages/server/src/routes/reporting.ts b/packages/server/src/routes/reporting.ts index bf79ce9..9260914 100644 --- a/packages/server/src/routes/reporting.ts +++ b/packages/server/src/routes/reporting.ts @@ -1,23 +1,154 @@ /** - * GET /api/reporting/events/:id/summary – event revenue & sell-through - * GET /api/reporting/events/:id/bidders – bidder activity report - * GET /api/reporting/events/:id/audit-log – full audit log + * GET /api/reporting/events/:id/summary – revenue, sell-through, item count + * GET /api/reporting/events/:id/bidders – per-bidder activity + invoice status + * GET /api/reporting/events/:id/audit-log – full audit log with pagination */ import { Router } from "express"; +import { prisma } from "../lib/prisma.js"; import { requireAuth, requireRole } from "../middleware/auth.js"; export const reportingRouter = Router(); const adminOnly = requireRole("admin", "event_manager"); -reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Event summary ────────────────────────────────────────────────────────────── +reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, async (req, res) => { + const eventId = req.params["id"]!; + + const [event, auctions, invoices, donations] = await Promise.all([ + prisma.auctionEvent.findFirst({ + where: { id: eventId, organizationId: req.auth!.organizationId }, + }), + prisma.auction.findMany({ + where: { eventId }, + include: { + items: { + select: { + id: true, + state: true, + currentHighBid: true, + fairMarketValue: true, + }, + }, + }, + }), + prisma.invoice.findMany({ where: { eventId } }), + prisma.donation.findMany({ where: { eventId } }), + ]); + + if (!event) { + res.status(404).json({ error: "Event not found" }); + return; + } + + const allItems = auctions.flatMap((a) => a.items); + const soldItems = allItems.filter((i) => i.state === "sold" || i.state === "closed"); + + const grossRevenue = soldItems.reduce( + (sum, i) => sum + Number(i.currentHighBid ?? 0), + 0, + ); + const totalDonations = donations.reduce((sum, d) => sum + Number(d.amount), 0); + const totalPaid = invoices.reduce((sum, inv) => sum + Number(inv.paidAmount), 0); + const totalOutstanding = invoices + .filter((inv) => inv.status !== "paid" && inv.status !== "void") + .reduce((sum, inv) => sum + (Number(inv.totalAmount) - Number(inv.paidAmount)), 0); + + res.json({ + event, + items: { + total: allItems.length, + sold: soldItems.length, + sellThroughPct: + allItems.length > 0 + ? Math.round((soldItems.length / allItems.length) * 100) + : 0, + }, + revenue: { + gross: grossRevenue, + donations: totalDonations, + total: grossRevenue + totalDonations, + collected: totalPaid, + outstanding: totalOutstanding, + }, + }); }); -reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Bidder activity ──────────────────────────────────────────────────────────── +reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, async (req, res) => { + const eventId = req.params["id"]!; + + const enrollments = await prisma.bidderEventEnrollment.findMany({ + where: { eventId }, + include: { + bidder: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + orderBy: { bidder: { lastName: "asc" } }, + }); + + const invoices = await prisma.invoice.findMany({ + where: { eventId }, + select: { bidderId: true, totalAmount: true, paidAmount: true, status: true }, + }); + + const invoiceByBidder = Object.fromEntries( + invoices.map((inv) => [inv.bidderId, inv]), + ); + + const wonItemsByBidder = await prisma.auctionItem.groupBy({ + by: ["currentHighBidderId"], + where: { + state: { in: ["sold", "closed"] }, + auction: { eventId }, + currentHighBidderId: { not: null }, + }, + _count: { id: true }, + _sum: { currentHighBid: true }, + }); + + const wonMap = Object.fromEntries( + wonItemsByBidder.map((row) => [ + row.currentHighBidderId, + { count: row._count.id, total: Number(row._sum.currentHighBid ?? 0) }, + ]), + ); + + const result = enrollments.map((e) => ({ + ...e, + invoice: invoiceByBidder[e.bidderId] ?? null, + won: wonMap[e.bidderId] ?? { count: 0, total: 0 }, + })); + + res.json(result); }); -reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, (_req, res) => { - res.status(501).json({ error: "Not implemented" }); +// ── Audit log ────────────────────────────────────────────────────────────────── +reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, async (req, res) => { + const eventId = req.params["id"]!; + const page = parseInt(String(req.query["page"] ?? "1"), 10); + const limit = Math.min(parseInt(String(req.query["limit"] ?? "100"), 10), 500); + const skip = (page - 1) * limit; + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + where: { eventId }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + include: { + staffUser: { select: { name: true, email: true, role: true } }, + }, + }), + prisma.auditLog.count({ where: { eventId } }), + ]); + + res.json({ logs, total, page, pages: Math.ceil(total / limit) }); }); diff --git a/packages/server/src/routes/webhooks.ts b/packages/server/src/routes/webhooks.ts index 4057aaa..dbc073b 100644 --- a/packages/server/src/routes/webhooks.ts +++ b/packages/server/src/routes/webhooks.ts @@ -1,16 +1,140 @@ /** - * POST /api/webhooks/stripe – Stripe webhook handler (raw body required) + * POST /api/webhooks/stripe + * + * Handles payment_intent.succeeded and payment_intent.payment_failed. + * Raw body is required for signature verification (mounted before express.json()). */ import { Router } from "express"; import express from "express"; +import Stripe from "stripe"; +import { prisma } from "../lib/prisma.js"; export const webhooksRouter = Router(); -// Raw body needed for Stripe signature verification webhooksRouter.post( "/stripe", express.raw({ type: "application/json" }), - (_req, res) => { - res.status(501).json({ error: "Not implemented" }); + async (req, res) => { + const sig = req.headers["stripe-signature"]; + const secret = process.env["STRIPE_WEBHOOK_SECRET"]; + + if (!sig || !secret) { + res.status(400).json({ error: "Missing signature or webhook secret" }); + return; + } + + let event: Stripe.Event; + try { + const stripe = new Stripe(process.env["STRIPE_SECRET_KEY"] ?? "", { + apiVersion: "2024-04-10", + }); + event = stripe.webhooks.constructEvent(req.body as Buffer, sig, secret); + } catch (err) { + console.error("[webhook] signature verification failed", err); + res.status(400).json({ error: "Invalid signature" }); + return; + } + + try { + switch (event.type) { + case "payment_intent.succeeded": + await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent); + break; + + case "payment_intent.payment_failed": + await handlePaymentFailed(event.data.object as Stripe.PaymentIntent); + break; + + default: + // Ignore other event types + break; + } + } catch (err) { + console.error(`[webhook] error handling ${event.type}`, err); + // Return 200 so Stripe doesn't retry β€” log and investigate separately + } + + res.json({ received: true }); }, ); + +// ── Handlers ─────────────────────────────────────────────────────────────────── + +async function handlePaymentSucceeded(intent: Stripe.PaymentIntent): Promise { + const { invoiceId, bidderId, eventId, campaignId } = intent.metadata; + + // ── Invoice payment ───────────────────────────────────────────────────────── + if (invoiceId) { + const payment = await prisma.payment.findFirst({ + where: { stripePaymentIntentId: intent.id }, + }); + + if (payment) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: "succeeded" }, + }); + } + + const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } }); + if (invoice) { + const newPaid = Number(invoice.paidAmount) + Number(intent.amount) / 100; + const total = Number(invoice.totalAmount); + const status = newPaid >= total ? "paid" : "partially_paid"; + + await prisma.invoice.update({ + where: { id: invoiceId }, + data: { paidAmount: newPaid, status }, + }); + + await prisma.auditLog.create({ + data: { + eventId, + action: "invoice_paid", + entityType: "Invoice", + entityId: invoiceId, + payload: { amount: intent.amount / 100, status }, + }, + }); + + console.log(`[webhook] invoice ${invoiceId} β†’ ${status} ($${newPaid})`); + } + } + + // ── Donation / paddle raise ───────────────────────────────────────────────── + if (!invoiceId && bidderId) { + const donation = await prisma.donation.findFirst({ + where: { stripePaymentIntentId: intent.id }, + }); + + if (donation && campaignId) { + await prisma.paddleRaiseCampaign.update({ + where: { id: campaignId }, + data: { totalRaised: { increment: Number(intent.amount) / 100 } }, + }); + + const campaign = await prisma.paddleRaiseCampaign.findUnique({ + where: { id: campaignId }, + }); + + if (campaign) { + console.log( + `[webhook] paddle raise ${campaignId} total β†’ $${Number(campaign.totalRaised)}`, + ); + } + } + } +} + +async function handlePaymentFailed(intent: Stripe.PaymentIntent): Promise { + const payment = await prisma.payment.findFirst({ + where: { stripePaymentIntentId: intent.id }, + }); + if (payment) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: "failed" }, + }); + console.warn(`[webhook] payment failed for intent ${intent.id}`); + } +} diff --git a/packages/server/src/services/scheduler.ts b/packages/server/src/services/scheduler.ts new file mode 100644 index 0000000..81b8c77 --- /dev/null +++ b/packages/server/src/services/scheduler.ts @@ -0,0 +1,117 @@ +/** + * Silent auction window scheduler. + * + * Polls every 10 seconds for windows whose closesAt has passed, closes them, + * marks each item as "closed", and broadcasts silent_item_closed to the event room. + * + * This runs entirely on the local server β€” no external dependencies β€” so it + * continues working when the internet is unavailable. + */ +import { prisma } from "../lib/prisma.js"; +import type { Server } from "socket.io"; +import type { + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, +} from "@storybid/shared"; + +type IO = Server; + +const POLL_INTERVAL_MS = 10_000; + +export function startScheduler(io: IO): void { + console.log("[scheduler] starting silent auction window poller"); + + setInterval(() => { + void closeExpiredWindows(io); + }, POLL_INTERVAL_MS); +} + +async function closeExpiredWindows(io: IO): Promise { + const now = new Date(); + + // Find open windows whose close time has passed + const expiredWindows = await prisma.silentAuctionWindow.findMany({ + where: { + status: "open", + closesAt: { lte: now }, + }, + include: { + items: { + where: { state: { notIn: ["sold", "passed", "closed"] } }, + include: { auction: { select: { eventId: true } } }, + }, + }, + }); + + for (const window of expiredWindows) { + // Mark the window closed + await prisma.silentAuctionWindow.update({ + where: { id: window.id }, + data: { status: "closed" }, + }); + + // Close each item still in the window + for (const item of window.items) { + const closed = await prisma.auctionItem.update({ + where: { id: item.id }, + data: { state: "closed" }, + }); + + // Write audit log entry + await prisma.auditLog.create({ + data: { + eventId: item.auction.eventId, + action: "item_auto_closed", + entityType: "AuctionItem", + entityId: item.id, + payload: { + windowId: window.id, + winnerId: closed.currentHighBidderId, + finalAmount: closed.currentHighBid + ? Number(closed.currentHighBid) + : null, + }, + }, + }); + + // Broadcast to event room + io.to(`event:${item.auction.eventId}`).emit("silent_item_closed", { + itemId: item.id, + winnerId: closed.currentHighBidderId, + finalAmount: closed.currentHighBid ? Number(closed.currentHighBid) : null, + }); + + console.log( + `[scheduler] closed item ${item.id} (lot ${item.lotNumber}) ` + + `winner=${closed.currentHighBidderId ?? "none"} ` + + `amount=${closed.currentHighBid ?? 0}`, + ); + } + } + + // Also open any windows whose opensAt has arrived + const pendingWindows = await prisma.silentAuctionWindow.findMany({ + where: { + status: "pending", + opensAt: { lte: now }, + closesAt: { gt: now }, + }, + include: { auction: { select: { eventId: true } } }, + }); + + for (const window of pendingWindows) { + await prisma.silentAuctionWindow.update({ + where: { id: window.id }, + data: { status: "open" }, + }); + + io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", { + windowId: window.id, + closesAt: window.closesAt.toISOString(), + }); + + console.log(`[scheduler] opened window ${window.id} (${window.name})`); + } +} diff --git a/packages/server/src/socket/live-auction.ts b/packages/server/src/socket/live-auction.ts index bfe01b1..3b33f3c 100644 --- a/packages/server/src/socket/live-auction.ts +++ b/packages/server/src/socket/live-auction.ts @@ -128,13 +128,36 @@ export function registerLiveAuctionHandlers(io: IO, socket: Sock): void { socket.on("auctioneer_accept_bid", async (payload) => { if (!isStaff(socket.data.role)) return; + // Resolve paddle: β†’ real bidderId via event enrollment + let bidderId = payload.bidderId; + if (bidderId.startsWith("paddle:")) { + const paddleNumber = bidderId.slice(7); + const item = await prisma.auctionItem.findUnique({ + where: { id: payload.itemId }, + include: { auction: { select: { eventId: true } } }, + }); + if (item) { + const enrollment = await prisma.bidderEventEnrollment.findFirst({ + where: { + eventId: item.auction.eventId, + paddleNumber, + }, + }); + if (!enrollment) { + console.warn(`[live] spotter: paddle ${paddleNumber} not found in event`); + return; + } + bidderId = enrollment.bidderId; + } + } + const result = await placeBid({ itemId: payload.itemId, - bidderId: payload.bidderId, + bidderId, amount: payload.amount, originMode: "public", - deviceId: socket.id, // spotter device = socket id - clientSeq: Date.now(), // floor bids use server timestamp as seq + deviceId: socket.id, + clientSeq: Date.now(), clientCreatedAt: new Date(), });