Architecture

How the Face-app maps onto the existing Zooly system

Overview

flowchart LR subgraph Client["face-app @ face.zooly.ai (Vite, public)"] LB["Board screen /:board<br/>(root redirects to /world-cup)<br/>search: switch board / find team"] GDPR["GDPR consent popup"] LEADS["For Teams / For Brands forms"] ADMIN["/admin (Cognito-gated)"] end subgraph Backend["zooly-app (Next.js) — shared backend"] API["GET /api/merch/leaderboard/:board<br/>GET .../search<br/>POST .../link, .../stat<br/>/api/merch/admin/*"] SRV["@zooly/team-leaderboard-srv"] DB[("Postgres via @zooly/db access layer")] end subgraph Store["Merch storefront (existing)"] EXP["Shared campaign (store) experience:<br/>selfie → result (share/download)"] end LB -- "fetch() (CORS)" --> API LEADS --> API ADMIN -- "adminApi() (requireAdmin)" --> API API --> SRV --> DB LB -- "on select: jump to store URL + ?bt=<boardTeamId>" --> EXP EXP -- "on face-paint complete: record stat (BoardTeam)" --> API

Repo conventions

Per .cursor/rules/general-instructions.mdc:

  • face-app is a Vite app with no backend of its own — it calls the shared zooly-app backend over CORS.
  • API route.ts only does param parsing, auth, and errors; business logic lives in packages (@zooly/db access + the @zooly/team-leaderboard-srv service module).
  • The app entry only mounts a package component; client packages stay React (inline styles here); every rendered div gets a data-testid.
  • DB tables never leave @zooly/db; all access goes through the access layer (no table exports).

How concepts map onto the system

Face-app conceptBackingNotes
Board (a league, the URL slug)NEW merch_leaderboardOwn chrome + kill switch; selected by slug; default = world-cup.
BoardTeam (a team in a league)NEW merch_board_team (leaderboardId → board, campaignId → store)Belongs to one board; holds the per-board seedCount + stat.
Merch store (reusable face paint)existing merch_campaignMany BoardTeams → one store.
Per-board stat (% who did face paint)COUNT of merch_leaderboard_stat_event + BoardTeam seedCountpct = BoardTeam count / sum within the board; highest first.
BoardTeam image / colorsmerch_board_team.flagImgUrl / mainColor / colors[] / facePaintImageUrls[]Flag image, bar color, swatch, example photos.
Click target → "store of Spain"BoardTeam's campaign storefront URL (built at runtime)Jumps straight into selfie/upload.
Sponsor logoper-board sponsorLogoUrl (optional)None at launch.

Packages involved

PackageRole
@zooly/team-leaderboard-client (packages/team-leaderboard/client)Board screen, search, handoff, admin UI, lead forms
@zooly/team-leaderboard-srv (packages/team-leaderboard/srv)Board response, search, session link, completion credit
@zooly/db (packages/db)Schema + access layer for boards, teams, stat events, leads
@zooly/consent (packages/consent)Shared cookie-consent popup + useConsent hook
@zooly/util (packages/util)Consent state (cookie scoped to .zooly.ai)
apps/zooly-appHosts the public + admin API routes

Existing pieces reused (and only these)

  • merch_campaign (the actual stores) and the downstream merch experience (selfie → result).
  • merchCampaignOpenWhere() to surface only live stores.
  • merch_session — extended with the credited BoardTeam (board_team_id).
  • Public merch API conventions: the shared CORS helper apps/zooly-app/app/api/merch/cors.ts (+ ALLOWED_DOMAINS_CORS) and S3_BUCKET_URL asset resolution.
  • The same zooly-auth SSO + requireAdmin middleware that powers merch-admin.

Handoff + crediting flow

  1. Select a BoardTeam on the board screen → MainApp.goToStore navigates to the campaign storefront URL (built at runtime from the campaign's accountSlug + slug) with ?bt=<boardTeamId>, honoring the board's storeLinkTarget (same / new tab).
  2. Link — the merch store landing (campaign-landing-page.tsx) reads ?bt= and, once the fresh session exists, POSTs to /api/merch/leaderboard/linklinkSessionToBoardTeamsetSessionBoardTeam (persists board_team_id on merch_session).
  3. Creditai/generate/route.ts calls recordCompletionFromSession (non-blocking) after generationCompletedAt. It reads the session's boardTeamId and inserts one stat event. It is a no-op for direct visits (no boardTeamId) and idempotent via unique(merchSessionId, eventType).