topofthetown

TopOfTheTown

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

Repository layout

.
├── 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

How a bid flows

  1. Browser: the bid dialog validates input with the same sanitize.ts the server uses. It re-encodes the logo through a canvas (max 640px, metadata stripped) and POSTs JSON to /api/checkout.
  2. 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.
  3. Stripe → stripeWebhook verifies the signature against req.rawBody. On checkout.session.completed (or async_payment_succeeded) it:
    • copies the logo to public spotlights/{bidId}/logo.{ext} and mints a download URL;
    • in one Firestore transaction: re-computes the minimum, archives the previous reign to /spotlight_history/{prevBidId} (outbid or expired), writes /spotlight/current, writes /transactions/{sessionId}, and marks the bid paid.
  4. Edge cases
    • Duplicate delivery: /transactions/{sessionId} already exists, so the handler returns without doing anything.
    • Outbid while on the Stripe page: the transaction records refund_pending, then an automatic Stripe refund runs with an idempotency key.
    • Amount/currency mismatch or missing bid: automatic refund.
    • Refund API failure: status becomes refund_failed and a REFUND NEEDED MANUALLY error is logged. Alert on that string.
    • Session expired / async payment failed: the pending logo is deleted and the bid is marked expired/failed.
    • Any other error: returns 500 so Stripe retries. Every step is idempotent.
  5. 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.

First-time setup

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.

Local development

# 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.

CI/CD (GitHub Actions)

.github/workflows/deploy.yml works like this:

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.

Hardening checklist before launch