Frontend

Routing, board screen, search, handoff, and consent

All React code lives in packages/team-leaderboard/client/ (@zooly/team-leaderboard-client). apps/face-app/ is a thin Vite wrapper that mounts the package's App. Components use inline styles (Lato font) to preserve the original Figma look, and every rendered div carries a data-testid.

App plumbing

  • API + env helpers and response types:
    • packages/team-leaderboard/client/src/lib/env.ts — resolves VITE_APP_URL (backend) and VITE_AUTH_URL.
    • packages/team-leaderboard/client/src/lib/api.tsfetchBoard, searchLeaderboard, response types.
    • packages/team-leaderboard/client/src/env.d.ts — Vite env typings.
  • Dev port :3009 (apps/face-app/vite.config.ts, apps/face-app/package.json).
  • SPA deep-link fallback for /admin and board slugs via apps/face-app/vercel.json.

Production env gotcha: VITE_APP_URL / VITE_AUTH_URL must include the https:// scheme (e.g. https://dev.zooly.ai). Without it, client fetches resolve relative to the current origin and return the SPA HTML fallback instead of JSON.

Routing

packages/team-leaderboard/client/src/app/routes.tsx:

  • /redirect to /world-cup (the isDefault board) so there's always a slug.
  • /:board → that board's ranked BoardTeams (/world-cup, /nba, /nba-finals, …). The board param is normalized (lowercased, hyphenated). An unknown slug falls back to the default board / a friendly empty state.
  • /for-teams, /for-brands → lead-capture pages.
  • /admin/* → the admin area (see Admin).

Board screen

packages/team-leaderboard/client/src/app/components/MainApp.tsx:

  • Fetches the board from GET /api/merch/leaderboard/:board (no hardcoded data — the old Figma TEAMS / NBA_TEAMS / mock screens were removed).
  • Renders board chrome (title, subtitle, cycling hero carousel, optional sponsor logo), the ranked team list with stat bars, color swatches, and <0.01% small-value formatting.
  • Loading / empty / error states with data-testid (board-loading, team-list-empty, board-error).
  • Responsive: fluid widths with max-width caps (hero max-width: 408px; aspect-ratio: 1/1; search box and lists max-width: 480px) and clamp() font sizes, with 16px side padding so nothing is clipped on small screens.

Search / autocomplete

The search box suggests boards and BoardTeams from 3+ characters (debounced, via /search). Picking a board navigates to its slug; picking a BoardTeam navigates to that board and enters its store. The "×" clears the search and restores the current board.

Handoff + credit

MainApp.goToStore navigates to the campaign storefront URL with ?bt=<boardTeamId> (honoring the board's storeLinkTarget). A BoardTeam with no live campaign has a null target and its row is non-actionable.

On same-tab navigation a full-screen loader overlay (data-testid="navigating-overlay") is shown immediately and cleared on pageshow (so it doesn't stick when the user hits Back via bfcache).

The merch store landing (packages/merch/client/src/app/pages/campaign-landing-page.tsx) reads ?bt= and POSTs to /api/merch/leaderboard/link once the session exists. Because the store has one design + only the digital-image product, the experience skips the design (B1) and product (B2) steps and lands on selfie/upload — this is the experience's normal behavior for a single-option store (selection steps reappear automatically if a store later gains more options).

Lead forms

ForTeamsPage.tsx / ForBrandsPage.tsx wire their forms to the leads API. Fields: org/team name, contact person, email, phone, message; on success the submission appears in the admin Leads view.

The consent UI comes from the shared @zooly/consent package and is mounted at the client root (packages/team-leaderboard/client/src/app/App.tsx); the old cosmetic popup in MainApp.tsx was removed.

  • @zooly/consentCookieConsent popup (Accept all / Reject all) + useConsent hook.
  • @zooly/util (packages/util/src/utils/consent.ts) — consent state persisted in a cookie scoped to the registrable parent domain (.zooly.ai), so one decision is shared across all subdomains (face., dev., auth., …). Falls back to a host-only cookie on localhost. API: getConsent / hasDecidedConsent / hasAnalyticsConsent / setConsent / onConsentChange.
  • Non-essential analytics are gated: packages/merch/client/src/lib/log-merch-event.ts early-returns when !hasAnalyticsConsent() (no journey session id, no /api/journey-event POST until consent).

Branding

  • Tab title: "Zooly Face Paint App"; favicon: the Zooly logo at apps/face-app/public/favicon.svg (apps/face-app/index.html).