# Mobile app (Expo)

The app is **Expo Router** (file-based routing) on React Native `0.81.5` / React
`19.1.0`, TypeScript, Expo SDK 54, with the new architecture enabled. Entry is
`expo-router/entry`; `typedRoutes` is on.

## Provider tree

Everything is composed in the root layout, `frontend/app/_layout.tsx`. Its default
export is `Sentry.wrap(RootLayout)`, and it also **exports an `ErrorBoundary`** —
Expo Router's per-segment error boundary convention, which is the app's global
crash screen (there is no separate `app/+error.tsx`; this is the functional
equivalent).

```mermaid
graph TB
    Sentry["Sentry.wrap + Sentry.init<br/>DSN-gated, 10% traces"]
    SAP["SafeAreaProvider"]
    PH["PostHogProvider + SurveyProvider"]
    SP["StoreProvider"]
    AP["AuthProvider"]
    Stack["Stack navigator<br/>headerShown false"]
    EB["ErrorBoundary<br/>Try Again / Return Home + captureException"]

    Sentry --> SAP --> PH --> SP --> AP --> Stack
    Stack -.wraps.-> EB
```

Fonts (Space Grotesk, Inter, Bebas Neue) load via `useFonts`; a spinner shows
until they resolve. A `usePathname` effect calls `posthog.screen()` on every route
change.

## Route groups

Routes live under `frontend/app/`. Parenthesized groups do not add URL segments.

```mermaid
graph LR
    Root["app/index.tsx<br/>landing + role redirect"]
    Auth["(auth)<br/>login · signup · forgot-password"]
    Main["(main)<br/>consumer app"]
    Brand["(brand)<br/>seller portal · own _layout"]
    Admin["(admin)<br/>dashboard · products · stores"]

    Root --> Auth
    Root --> Main
    Root --> Brand
    Root --> Admin
```

`app/index.tsx` reads `onboarding_complete`, and if a user is already logged in
redirects by role: `brand` goes to the seller dashboard, everyone else to the
consumer home. Otherwise it renders a landing explainer and a three-way path
chooser (Shop &amp; Try On, Sell Apparel, Brand Portal).

The full screen inventory is documented under
[Product & Features](../product/overview.md) — the consumer screens on
[Consumer app](../product/consumer-app.md), the portal on
[Seller portal](../product/seller-portal.md).

## State management

**No Redux, Zustand, or React Query.** State is React Context + component
`useState` + AsyncStorage for persistence. There are exactly two providers:

- **`AuthContext`** (`src/context/AuthContext.tsx`) — holds `user`, `token`,
  `isLoading`. `isTokenExpired()` decodes the JWT payload and treats a token
  expiring within 30s (or unparseable) as expired. `loadStoredAuth()` runs on
  mount: an expired token wipes the session, otherwise it calls `GET /api/auth/me`.
  This is the "token validated at load" behavior. Exposes `login`, `signup`,
  `logout`, `deleteAccount`, `updateProfile`, `uploadPhoto`, `refreshUser`.
- **`StoreContext`** (`src/context/StoreContext.tsx`) — a multi-tenant / whitelabel
  theming layer (`currentStore`, `stores`, `theme`). It carries its own default
  purple theme distinct from the app's cream design system and is largely dormant
  relative to the main consumer flow.

### AsyncStorage keys

There is no central storage wrapper; keys are string literals. The authoritative
list is `clearUserSession()` in `AuthContext.tsx`. Notable keys: `auth_token`,
`user_role`, `bipa_consent_given` / `bipa_consent_timestamp`,
`data_disclosure_accepted`, `onboarding_complete`, `user_path`,
`user_body_photo` / `user_body_photo_exists`, and `user_measurements`.

:::warning[Measurements are stored under bare keys]
`user_measurements` is a JSON object with **bare** keys — `chest`, `waist`,
`hips`, `shoulder`, `height` — written in `fit-setup.tsx` `runAnalysis()` by
mapping the backend's `_cm`-suffixed keys at write time. Do not add the `_cm`
suffix on the frontend; the normalization is deliberate.
:::

## Design system

Tokens are canonical (hardcoded color/font/spacing literals are forbidden). Three
files under `src/theme/`:

| File | Exports |
|---|---|
| `colors.ts` | `COLORS` (Design System v4 — Dark Editorial: near-black backgrounds, white-alpha borders, cream `#D8D2C8` accent, fit-score functional colors), plus `TYPOGRAPHY`, `SPACING`, `RADIUS`, `SHADOWS`, `BOTTOM_NAV` |
| `typography.ts` | `FONT_FAMILIES` and the canonical `DS` scale — Bebas Neue for display, Inter for UI/body (Space Grotesk is legacy) |
| `motion.ts` | `DURATION`, `SPRING`, `EASING` — all springs use `useNativeDriver: true` |

Key reusable components in `src/components/`: `Button`, `ProductCard` (memo'd),
`ScreenWrapper` (SafeAreaView shell), `BottomNav` (floating blurred pill nav),
`DrapeRenderLoader` (the try-on loading experience), `MeasurementWheelPicker` (iOS
drum-roll picker with height/weight/size ranges), `ProductInfoTabs`,
`DrapeWordmark`, `Skeleton`, `TextureOverlay` (film-grain overlay).

## Talking to the backend

:::note[There is no central API client]
Every screen and context declares `const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL`
at module top and calls `fetch()` directly — 32 files do this. The base URL is
injected per EAS build profile in `eas.json` (all profiles currently point at the
Render backend); it is not in `app.json extra` or `src/constants`.
:::

- **Auth header:** JWT sent as `Authorization: Bearer <token>`, from
  `useAuth().token` or directly from `AsyncStorage` (the long-running try-on
  screen reads AsyncStorage so the token survives the render).
- **Error handling:** response bodies are parsed defensively
  (`.json().catch(() => null)`) so a non-JSON 500/502 is not masked as a network
  error. User-triggered fetches set visible error states with retry
  (home, history, saved, catalog all do this). The try-on and fit-setup flows
  classify errors into friendly copy by type (rate-limit / timeout / abort /
  network).
- **AbortController:** stored in `useRef` and aborted in a `useEffect` cleanup on
  the try-on screen; the poll loop also checks `signal.aborted` between ticks.

## Analytics & monitoring

- **PostHog** (`src/config/posthog.ts`) — product analytics + **session replay**.
  Text inputs are masked; body photos are masked at the component level
  (`accessibilityLabel="ph-no-capture"`); the Stripe sheet is masked as a
  sandboxed view. Session replay is enabled only when `EXPO_PUBLIC_APP_ENV` is
  `beta` — which currently includes production builds, a flagged item on the
  [shipping checklist](../shipping/overview.md).
- **Sentry** (`@sentry/react-native`) — initialized in `_layout.tsx` (DSN-gated,
  10% traces), app wrapped in `Sentry.wrap`, and `captureException` fired from the
  global `ErrorBoundary`.

:::info[The try-on viewport is frozen]
The render viewport in `try-on/[productId].tsx` is explicitly **FROZEN** per
`frontend/CLAUDE.md` — hero height, image wrapper, `contentFit`, and transforms
must not be changed without written approval. Treat that file's layout as locked.
:::
