Face-app Overview

Leaderboard landing experience in front of the Zooly face-paint flow

What is the Face-app?

The Face-app (Face Leaderboard) is a standalone Vite SPA hosted at face.zooly.ai that sits in front of the existing Zooly face-paint experience. A fan lands on a board, sees a ranked list of teams with stat bars (the share of people who got that team's face paint), can search/autocomplete to jump to another board or team, and on selecting an entry is handed off into the face-paint experience for that team's store (selfie → AI result → share/download).

The idea is bigger than the World Cup: face paint for any team, any sport, any country/state — the team's colors, or the flag if it's a country/state.

The app runs at apps/face-app/ (thin Vite wrapper) with all React code in packages/team-leaderboard/client/, service logic in packages/team-leaderboard/srv/, and a Drizzle-backed API on apps/zooly-app/. It reuses the existing merch stores (campaigns) and the merch experience flow for the actual face-paint journey — nothing more.

Core domain concept (two entities + a shared store)

  • Board = a league (merch_leaderboard): the unit the URL selects (/world-cup, /nba, /nba-finals). Carries its own chrome (title, hero carousel, sponsor slot, footer) and kill switch, and a set of BoardTeams ranked by its own stat.
  • BoardTeam = a team in a league (merch_board_team) — e.g. spain-football on the World Cup board. It belongs to exactly one board, carries its identity (name, colors, flag image), holds the per-board stat, and links to a merch store (campaign) for the actual face paint.
  • Merch store (campaign) = the reusable face-paint experience (merch_campaign, already exists). Different BoardTeams can point to the same storespain-football and spain-basketball are different BoardTeams (different boards, separate stats) but share the one Spain face-paint store. The store is what's reused, not the BoardTeam.

When a face-paint is completed, we credit the BoardTeam the user came from — the session records which BoardTeam this generation counts for, and that BoardTeam's (per-board) counter goes up.

Key features

  • Unified multi-board UI: there are multiple boards (one per league), but one board is shown at a time, chosen by the URL slug. The root / redirects to /world-cup so there's always a slug.
  • Backend-driven board screen: ranked teams, stat bars, color swatches, and the cycling hero carousel all come from the API (no hardcoded data).
  • Server-side search / autocomplete (min 3 characters) across boards and BoardTeams.
  • Store handoff with credit: selecting a team navigates to the campaign storefront with ?bt=<boardTeamId>; the store landing persists which BoardTeam to credit on the merch session.
  • Per-board stats from an event log: each completed face-paint inserts one idempotent stat event; live count = seedCount + COUNT(events), percentage is computed within the board.
  • Admin area at face.zooly.ai/admin (Cognito-gated) to manage Boards, BoardTeams, and Leads.
  • Shared GDPR cookie consent across all *.zooly.ai surfaces, gating non-essential analytics.
  • Lead capture via For Teams / For Brands forms.

System components

1. Vite SPA (frontend)

apps/face-app/ is a thin Vite wrapper (dev port :3009). All React code lives in packages/team-leaderboard/client/ (@zooly/team-leaderboard-client) — board screen, search, admin pages, lead forms. Styled inline (Lato font) to preserve the original Figma look.

2. Backend API (zooly-app)

Public CORS routes under apps/zooly-app/app/api/merch/leaderboard/ (board read, search, link, stat) and admin routes under apps/zooly-app/app/api/merch/admin/ (requireAdmin). Thin routers — business logic lives in packages.

3. Service logic (packages/team-leaderboard/srv)

@zooly/team-leaderboard-srv — board response composition, search, session linking, and completion crediting.

4. Database layer

@zooly/db — 3 new tables (merch_leaderboard, merch_board_team, merch_leaderboard_stat_event), a board_team_id column on merch_session, plus the access layer in packages/db/src/access/merch/merch-leaderboard.ts.

@zooly/consent (popup + useConsent hook) backed by consent state in @zooly/util — adopted by both the merch storefront and the face-app.

Domain topology

DomainAppPurpose
face.zooly.aiVite SPA (face-app)Production leaderboard
dev-face.zooly.aiVite SPA (face-app)Staging leaderboard
localhost:3009Vite dev serverLocal development
localhost:3004 / dev.zooly.aizooly-appBackend API

Data flow

Fan browser (Vite SPA, port 3009)
  | fetch() with CORS
zooly-app API routes (port 3004)
  | delegates to @zooly/team-leaderboard-srv + @zooly/db
PostgreSQL (merch_leaderboard, merch_board_team, merch_leaderboard_stat_event, merch_session)
  | on select: hand off to merch store experience (selfie -> result) with ?bt=<boardTeamId>

Next steps