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.
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.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_campaign, already exists). Different BoardTeams can point to the same store — spain-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.
/ redirects to /world-cup so there's always a slug.?bt=<boardTeamId>; the store landing persists which BoardTeam to credit on the merch session.seedCount + COUNT(events), percentage is computed within the board.face.zooly.ai/admin (Cognito-gated) to manage Boards, BoardTeams, and Leads.*.zooly.ai surfaces, gating non-essential analytics.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.
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.
@zooly/team-leaderboard-srv — board response composition, search, session linking, and completion crediting.
@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 | App | Purpose |
|---|---|---|
face.zooly.ai | Vite SPA (face-app) | Production leaderboard |
dev-face.zooly.ai | Vite SPA (face-app) | Staging leaderboard |
localhost:3009 | Vite dev server | Local development |
localhost:3004 / dev.zooly.ai | zooly-app | Backend API |
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>