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).
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 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) — holdsuser,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 callsGET /api/auth/me. This is the "token validated at load" behavior. Exposeslogin,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.
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
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>, fromuseAuth().tokenor directly fromAsyncStorage(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
useRefand aborted in auseEffectcleanup on the try-on screen; the poll loop also checkssignal.abortedbetween 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 whenEXPO_PUBLIC_APP_ENVisbeta— 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 inSentry.wrap, andcaptureExceptionfired from the globalErrorBoundary.
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.