A pay-to-play digital marquee for local businesses. One business reigns in the hero spotlight for 24 hours. Anyone can take the crown by bidding at least the current bid + $1.00. Past winners stay listed in the Roll of Fame directory for 30 days.
Stack: Next.js 16 (App Router, static export) · Tailwind v4 · shadcn/ui · Firestore · Cloud Storage · Cloud Functions v2 · Stripe Checkout · GitHub Actions → Firebase Hosting
.
├── app/ Next.js App Router (static export → out/)
│ ├── layout.tsx, page.tsx Home = Hero spotlight + Roll of Fame
│ ├── success/page.tsx Stripe success_url landing
│ └── globals.css Tailwind v4 theme + marquee-bulb frame
├── components/
│ ├── spotlight/ hero-spotlight · countdown · bid-dialog
│ ├── directory/ directory-grid (past 30 days)
│ └── ui/ shadcn primitives (button, dialog, input, label, field)
├── lib/
│ ├── firebase.ts client SDK singleton (+ emulator switch)
│ ├── prepare-logo.ts canvas resize / EXIF strip before upload
│ ├── hooks/ useSpotlight · useRecentSpotlights · useNow
│ └── utils.ts
├── functions/ Cloud Functions (Node 22, TypeScript)
│ ├── src/
│ │ ├── shared/ ← imported by BOTH web (@shared/*) and functions
│ │ │ ├── types.ts Firestore document types
│ │ │ ├── pricing.ts min-bid rule, reign length, caps
│ │ │ └── sanitize.ts input cleaning + validation
│ │ ├── checkout.ts createCheckoutSession (POST /api/checkout)
│ │ ├── webhook.ts stripeWebhook (POST /api/stripe/webhook)
│ │ ├── scheduled.ts archiveExpiredReign (every 5 min)
│ │ ├── config.ts secrets + params
│ │ └── lib/ admin SDK, Stripe client, image sniffing, reign helpers
│ └── test/ vitest unit tests
├── schemas/ JSON Schemas for the three Firestore document types
├── firebase.json Hosting (+ /api rewrites), Functions, rules
├── firestore.rules · storage.rules
└── .github/workflows/deploy.yml
sanitize.ts the server uses. It re-encodes the logo through a canvas (max 640px, metadata stripped) and POSTs JSON to /api/checkout.createCheckoutSession re-validates everything, sniffs the logo’s magic bytes (PNG/JPEG/WebP only, never SVG), and re-checks the bid against the live minimum. It then stages the logo at private pending/{bidId}/logo and the full payload, including the email, at /bids/{bidId}. Finally it creates a Stripe Checkout Session with metadata.bidId (plus a mirror of the business payload) and an idempotency key.stripeWebhook verifies the signature against req.rawBody. On checkout.session.completed (or async_payment_succeeded) it:
spotlights/{bidId}/logo.{ext} and mints a download URL;/spotlight_history/{prevBidId} (outbid or expired), writes /spotlight/current, writes /transactions/{sessionId}, and marks the bid paid./transactions/{sessionId} already exists, so the handler returns without doing anything.refund_pending, then an automatic Stripe refund runs with an idempotency key.refund_failed and a REFUND NEEDED MANUALLY error is logged. Alert on that string.expired/failed.archiveExpiredReign runs every 5 minutes. It moves an expired reign to history and marks the throne vacant, which resets the minimum to the $5 base bid.Change the business rules (base bid, $1 step, 24h reign, $10k cap) in functions/src/shared/pricing.ts.
npm install && npm install --prefix functions
cp .env.example .env.local # fill in Firebase web config
cp functions/.env.example functions/.env # PUBLIC_SITE_URL
# edit .firebaserc → your project id
firebase functions:secrets:set STRIPE_SECRET_KEY # sk_live_… / sk_test_…
firebase functions:secrets:set STRIPE_WEBHOOK_SECRET # whsec_… (from step below)
In the Firebase console, enable Firestore, Storage and the Blaze plan (required for Functions, Secrets and outbound Stripe calls).
Stripe webhook: Dashboard → Developers → Webhooks → add endpoint https://<your-domain>/api/stripe/webhook with events checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired. Copy its signing secret into STRIPE_WEBHOOK_SECRET.
# terminal 1 – emulators (put test secrets in functions/.secret.local)
npm run emulators
# terminal 2 – forward Stripe test webhooks to the emulator
stripe listen --forward-to http://127.0.0.1:5001/<project>/us-central1/stripeWebhook
# terminal 3 – Next.js (set NEXT_PUBLIC_USE_EMULATORS=true and NEXT_PUBLIC_CHECKOUT_URL in .env.local)
npm run dev
Pay with test card 4242 4242 4242 4242.
.github/workflows/deploy.yml works like this:
main: the same checks, then deploy Hosting, Functions, Firestore rules and Storage rules.Configure in GitHub → Settings → Secrets and variables → Actions:
| Kind | Name | Value |
|---|---|---|
| Secret | FIREBASE_SERVICE_ACCOUNT |
JSON key of a deploy service account |
| Variable | FIREBASE_PROJECT_ID, FIREBASE_API_KEY, FIREBASE_AUTH_DOMAIN, FIREBASE_STORAGE_BUCKET, FIREBASE_APP_ID |
Firebase web config |
| Variable | PUBLIC_SITE_URL |
e.g. https://topofthetown.web.app |
The deploy service account needs these roles: Firebase Admin, Cloud Functions Admin, Service Account User, Cloud Build Editor, Artifact Registry Administrator, Cloud Scheduler Admin, Secret Manager Viewer, and Firebase Rules Admin. Also create a production environment in GitHub if you want a manual approval gate.
Both package-lock.json files are included — commit them; npm ci in CI requires them.
createCheckoutSession to stop scripted checkout spam.bids.expiresAt for unpaid bids, or a cleanup job.REFUND NEEDED MANUALLY.