Frontend¶
The frontend/ React app. Vite + React 19 + TypeScript + Tailwind v4.
Structure¶
frontend/src/
├── main.tsx # Entry point, QueryClient, AuthProvider, i18n, analytics
├── router.ts # React Router v7 route table + trackPageView
├── pages/ # One file per route
│ ├── LandingPage.tsx
│ ├── TeaserPage.tsx
│ ├── ReportPage.tsx
│ ├── SignInPage.tsx
│ ├── SignUpPage.tsx
│ ├── MyReportsPage.tsx
│ ├── ComparePage.tsx
│ ├── AccountPage.tsx
│ └── TermsPage.tsx / PrivacyPage.tsx
├── sections/ # Large report sub-sections
│ ├── ExecutiveSummary.tsx
│ ├── DailyLifeDeepDive.tsx
│ ├── FamilyDeepDive.tsx
│ ├── SmartLivingDeepDive.tsx
│ └── FunFacts.tsx
├── components/
│ ├── layout/PageShell.tsx
│ └── ui/ # Reusable components
│ ├── ScoreGauge.tsx
│ ├── GradeCard.tsx
│ ├── TravelTimesCard.tsx
│ ├── AuthCheckoutModal.tsx # Combined auth + pricing
│ ├── ReportProblemModal.tsx # "Report a problem" feedback
│ ├── EmailCaptureForm.tsx # Teaser lead magnet
│ └── PreferenceQuiz.tsx # Personalized score quiz
├── charts/
│ ├── ScoreRadar.tsx
│ ├── PercentileBar.tsx
│ └── TaxComparisonBar.tsx
├── hooks/
│ ├── useReport.ts # The main "load a report" hook
│ ├── useTeaser.ts
│ ├── useTokens.ts
│ └── useChartTheme.ts # Dark-mode-aware chart theme
├── contexts/
│ └── AuthContext.tsx # Supabase session + signIn/signUp
├── api/
│ └── client.ts # All fetch calls to the Worker
├── lib/
│ └── supabase.ts # Supabase client (or null in dev)
├── mocks/
│ ├── api.ts # Dev-mode mock responses
│ └── cells.ts # Sample CellData objects
├── types/
│ └── report.ts # CellData, TeaserData, Grade types
├── utils/
│ ├── analytics.ts # trackEvent + EVENTS const
│ ├── areaVibes.ts # Hoodmaps-style descriptions by commune/zone
│ ├── compareMetrics.ts # Report comparison delta algorithm
│ ├── format.ts
│ └── grades.ts
└── i18n/
├── en.json
└── fr.json
Routes¶
Defined in src/router.ts:
| Path | Component | Auth |
|---|---|---|
/ |
LandingPage | — |
/teaser/:addressSlug |
TeaserPage | — |
/report/:addressSlug |
ReportPage | Required |
/auth/signin |
SignInPage | — |
/auth/signup |
SignUpPage | — |
/auth/forgot-password |
ForgotPasswordPage | — |
/my-reports |
MyReportsPage | Required |
/compare?ids=... |
ComparePage | Required |
/account |
AccountPage | Required |
/terms |
TermsPage | — |
/privacy |
PrivacyPage | — |
Auth-protected routes redirect to /auth/signin?returnTo=<currentPath>.
State management¶
- Server state:
@tanstack/react-queryfor all API data (teasers, reports, tokens, saved reports). Query keys are typed. Cache config:staleTime: Infinity, gcTime: 1hfor reports (they don't change mid-session). - Client state: React
useState/useReducer. No global store (Redux / Zustand). - Auth state:
AuthContextwraps the app, exposesuser,signIn,signUp,signOut. SupabaseonAuthStateChangedrives updates. - Language:
react-i18nextwith browser language detection + user override.
The key pages¶
LandingPage.tsx¶
- Hero with address search (Swiss Federal geocoder, no API key needed)
- "Know before you move" preview section with real
ScoreGaugecomponents +ScoreRadarchart for Daily Life dimensions + synthesized insights - Data credibility bar
- Pricing table
- FAQ, footer
TeaserPage.tsx¶
- Fetches
/api/teaser?lat=X&lng=YviauseTeaser - Shows 3
GradeCardcomponents + headlines + fun facts - Air quality badge (currently free)
EmailCaptureFormfor anonymous users- Blurred paywall preview
- "Unlock" CTA → opens
AuthCheckoutModal(or if user has tokens, shows a confirm dialog) - Handles
?checkout=successquery param → auto-navigate to report
ReportPage.tsx¶
- Fetches
/api/reports/by-addressfirst (saved reports, no token cost) - Falls back to
/api/report/unlock(spends a token) - Renders via
useReporthook which returns{ id, data } - Sections:
ExecutiveSummary— 3 score gauges with sub-scoresDailyLifeDeepDive— radar + percentile bars + key POIsFamilyDeepDive— schools, playgrounds, daycares, international schoolsSmartLivingDeepDive— tax comparison, air quality, noise, vibrancyFunFactsPreferenceQuiz(optional) — personalized scoringReportProblemModal— feedback button in the footer
AuthCheckoutModal.tsx (the conversion optimization)¶
Combined auth + pricing flow in a single modal. Before this existed, a new user had to go through 6 page loads to buy a report. Now it's 3.
Flow: - If user is not logged in → Step 1 (auth): email/password form with toggle between sign-in and sign-up - If user is logged in → Step 2 (pricing): 3 tier cards - If balance > 0: show "Use 1 token to unlock" primary CTA that skips Stripe entirely - Otherwise: tier selection → Stripe checkout redirect
step is derived from auth state, not stored — so the moment
onAuthStateChange fires a session, the modal flips to the pricing step
in place. No navigation.
Edge cases handled: - "Email already registered" → graceful drop into sign-in mode with email prefilled - Email verification required by Supabase → "check your email" state - Checkout error → inline retry - ESC / backdrop click → drop-off tracked with step annotation
The API client (src/api/client.ts)¶
Single file with all fetch calls. Key pattern:
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
const IS_DEV = import.meta.env.DEV;
export async function unlockReport(...): Promise<LoadedReport> {
if (IS_DEV && !API_BASE) {
// Fall through to mock data — see src/mocks/api.ts
const { mockFetchReport } = await import('../mocks/api');
return { id: 'dev-mock-report', data: await mockFetchReport(lat, lng) };
}
// Real fetch with auth header
const token = await getAccessToken();
const res = await fetch(`${API_BASE}/api/report/unlock`, { ... });
...
}
This lets the app run standalone (no Worker needed) during development and in Playwright tests.
The LoadedReport type is { id: string; data: CellData } — the id is
threaded through to the ReportProblemModal for feedback attribution.
Analytics¶
utils/analytics.ts exports:
trackPageView(path)— called fromrouter.tson navigationtrackEvent(name, params)— called from every conversion pointEVENTSconst — central registry of event names (prevents typos)setAnalyticsConsent(accepted)— GDPR consent flow
In dev, both functions log to console.debug with a [analytics] prefix
so you can verify events fire without a GA dashboard.
GA4 is only loaded after explicit consent (see main.tsx). The
VITE_GA_MEASUREMENT_ID env var is required for production.
33 events are instrumented, covering the full funnel: search → teaser → auth → checkout → unlock → post-purchase flows (feedback, compare). See the EVENTS const for the list.
i18n¶
Two JSON files: src/i18n/en.json and src/i18n/fr.json. Structure:
{
"hero": { "title": "...", "subtitle": "..." },
"scores": { "daily_life": "Daily Life", ... },
"labels": { "transit": "Transit", ... },
"report": { "executive_summary": "Summary", ... },
"auth_checkout": { "title": "Unlock your full report", ... },
"feedback": { ... },
"email_capture": { ... }
}
Rules:
- Every user-visible string goes through t('namespace.key')
- FR is the default language (Geneva is francophone)
- Natural French, not machine-translated
- Add keys in both files simultaneously
Dark mode¶
Tailwind v4's dark: modifier is used everywhere. The root HTML element
gets a dark class via a small script in index.html that checks
localStorage['theme'] on page load to avoid flash of wrong theme.
Charts (Nivo) use useChartTheme hook which returns a theme object
aware of dark mode.
Mock data¶
src/mocks/cells.ts has ~8 sample cells for dev mode. Each cell has the
full CellData shape. src/mocks/api.ts exports mockFetchTeaser /
mockFetchReport that pick the closest mock cell for a given lat/lng.
These are ONLY imported when IS_DEV && !API_BASE — production builds
tree-shake them out via dynamic imports (import('../mocks/api')).
Testing¶
tests/e2e/*.spec.ts — Playwright smoke tests. Run with:
npm run test:e2e # headless
npm run test:e2e:ui # Playwright UI
npm run test:e2e:headed # visible browser
Tests use the dev server with empty VITE_API_URL so the mocks kick in.
No real Supabase/Stripe/Worker needed.
Coverage: - Landing page: hero, search, preview section - Teaser page: grade cards, headlines, email capture - Auth modal: opens, toggles modes, ESC/backdrop close
Not yet covered (needs Supabase mocking): - Full purchase path - Report page (requires auth) - Saved reports list
Build¶
Runs tsc -b (typecheck) then vite build. Output in dist/.
Bundle sizes:
- index.js: ~410 KB gzipped
- mapbox-gl.js: ~1.6 MB (lazy-loaded when user opens a map)
- ReportPage.js: ~383 KB (lazy-loaded via React Router code splitting)
Next: Deployment & ops