Skip to content

Frontend

← Back to index

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-query for all API data (teasers, reports, tokens, saved reports). Query keys are typed. Cache config: staleTime: Infinity, gcTime: 1h for reports (they don't change mid-session).
  • Client state: React useState / useReducer. No global store (Redux / Zustand).
  • Auth state: AuthContext wraps the app, exposes user, signIn, signUp, signOut. Supabase onAuthStateChange drives updates.
  • Language: react-i18next with 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 ScoreGauge components + ScoreRadar chart for Daily Life dimensions + synthesized insights
  • Data credibility bar
  • Pricing table
  • FAQ, footer

TeaserPage.tsx

  • Fetches /api/teaser?lat=X&lng=Y via useTeaser
  • Shows 3 GradeCard components + headlines + fun facts
  • Air quality badge (currently free)
  • EmailCaptureForm for anonymous users
  • Blurred paywall preview
  • "Unlock" CTA → opens AuthCheckoutModal (or if user has tokens, shows a confirm dialog)
  • Handles ?checkout=success query param → auto-navigate to report

ReportPage.tsx

  • Fetches /api/reports/by-address first (saved reports, no token cost)
  • Falls back to /api/report/unlock (spends a token)
  • Renders via useReport hook which returns { id, data }
  • Sections:
  • ExecutiveSummary — 3 score gauges with sub-scores
  • DailyLifeDeepDive — radar + percentile bars + key POIs
  • FamilyDeepDive — schools, playgrounds, daycares, international schools
  • SmartLivingDeepDive — tax comparison, air quality, noise, vibrancy
  • FunFacts
  • PreferenceQuiz (optional) — personalized scoring
  • ReportProblemModal — 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 from router.ts on navigation
  • trackEvent(name, params) — called from every conversion point
  • EVENTS const — 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

npm run 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