Structure
Route groups, deep links, features, shared boundaries, and shared UI rules.
Route groups (src/app)
| Group | Contents |
|---|---|
(app)/ | Main tree — guests + authenticated. Five native tabs (home, shop, my-bag, loyalty, profile) plus orders, product detail, wishlist, reviews, addresses, beauty profile, notifications, and the profile settings subtree |
auth/ | The sign-in sheet, presented as a modal: login, verify (OTP), profile-completion (account details) |
(public)/ | search, marketing pages, legal pages |
(modals)/ | Bottom-sheet modals only: notification-permission, notification-preferences, select-avatar. Children inherit presentation: 'modal' |
duit-raya-flow.tsx | QR claim state machine — deliberately outside (modals) so it can be a card; modal-to-modal nav uses router.replace() |
The tabs import NativeTabs from expo-router/native-tabs.
Deep links
src/app/+native-intent.ts screens every incoming link against the allowlist in src/shared/utils/route-validation.ts before Expo Router resolves it. On Android a link that lands while the app starts would reach no one, or open with no Home behind it, so plugins/withLateLaunchLink.js and src/shared/utils/late-launch-link.ts hold it until (app) has built Home, and RootNavigator navigates to it then. The language switch reloads the JS runtime with Updates.reloadAsync, and it saves the Profile route in src/shared/utils/reload-route.ts first, so the app comes back on Profile instead of on the link it launched from.
Features (src/features)
17 domains: addresses, auth, beauty-profile, cart, checkout, home, loyalty, marketing, notifications, orders, product, product-list, profile-completion, search, settings, vouchers, wishlist.
Boundaries (eslint-enforced, not just convention)
src/shared/**must not import@/features/*(type imports allowed).- Features must not import runtime code from other features (type imports allowed; documented exceptions exist, e.g. checkout reading cart's
useHasPendingCartAdds). react-nativeTouchableOpacityis banned — usePressableOpacityfrom@/shared/components/ui.- Direct
react-native-render-htmlimports are banned. Render HTML throughHtmlContent(src/shared/components/ui/media/html-content.tsx), which drops head-only tags such as<meta>. Import it by path, not through the ui barrel, so render-html stays out of cold start. _internal/directories are private.
Shared UI rules
AppBottomSheet renders in its own Android window, and gesture handler only sees touches under a root in the same window. sheet-shell.android.tsx wraps the sheet content in its own GestureHandlerRootView, so gesture-handler pressables, pressto cards and pans work inside a sheet on Android. Any new window-level surface needs the same root. Handing one sheet off to the next needs no delay on SDK 58, and the filter hub closes one and opens the other in a single state update.
The sheet content stays a React child of wherever the sheet is rendered. When a sheet holds a text field, every ScrollView or FlatList that is a React ancestor of the sheet needs keyboardShouldPersistTaps="handled", or the first tap after typing only dismisses the keyboard. On iOS the Report Review sheet still loses that first tap, and that fix is open.
SelectPicker in src/shared/components/ui/primitives/select.tsx opens an AnchoredMenu for 1 to 6 options when the list is not searchable, and a SelectSheet otherwise. AnchoredMenu requires accessibilityLabel, owns the accessible trigger and its testID, and takes fit as 'fill' or 'content'. Pass it plain views as children, never a pressable. On Android the menu shows its title as a disabled first row and drops that row while a screen reader runs.
A date of birth is a calendar day. src/shared/utils/calendar-day.ts converts it with parseIsoDay and formatIsoDay, which give local midnight in the form and 'YYYY-MM-DD' on the wire. toISOString() never touches a day. The picker is @expo/ui/community/datetime-picker. Since @expo/ui 58.0.14 its Android dialog takes and returns local dates. Edit Profile passes the day unconverted and runs the answer through startOfLocalDay, because the pick comes back at the time of day of the value it was given. On iOS, Edit Profile shows an inline wheel that stays open until Done. Testing covers the time-zone reruns.
Home and Loyalty banner cards take their screen-reader label from src/shared/utils/banner-accessibility.ts, which folds the title, subtitle and call to action into one label.
Convex integration (src/integrations/convex/)
provider.tsx (ConvexReactClient with expectAuth: false so guest browsing works, bridged to Better Auth), auth-client.ts, hooks.ts (useConvexUserId — authenticated only when both Better Auth and Convex auth are ready), session-store.ts (MMKV-backed), secure-storage-proxy.ts (telemetry-wrapped SecureStore because @better-auth/expo swallows storage errors internally).