Skip to main content

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).

wraps

Sentry.wrap + Sentry.init
DSN-gated, 10% traces

SafeAreaProvider

PostHogProvider + SurveyProvider

StoreProvider

AuthProvider

Stack navigator
headerShown false

ErrorBoundary
Try Again / Return Home + captureException

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.

app/index.tsx
landing + role redirect

(auth)
login · signup · forgot-password

(main)
consumer app

(brand)
seller portal · own _layout

(admin)
dashboard · products · stores

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 & Try On, Sell Apparel, Brand Portal).

The full screen inventory is documented under Product & Features — the consumer screens on Consumer app, the portal on Seller portal.

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.

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/:

FileExports
colors.tsCOLORS (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.tsFONT_FAMILIES and the canonical DS scale — Bebas Neue for display, Inter for UI/body (Space Grotesk is legacy)
motion.tsDURATION, 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

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.
  • Sentry (@sentry/react-native) — initialized in _layout.tsx (DSN-gated, 10% traces), app wrapped in Sentry.wrap, and captureException fired from the global ErrorBoundary.
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.