diff --git a/.watchmanconfig b/.watchmanconfig index 0967ef4..57c35d4 100644 --- a/.watchmanconfig +++ b/.watchmanconfig @@ -1 +1,11 @@ -{} +{ + "ignore_dirs": [ + "android/build", + "android/app/build", + "android/app/.cxx", + "android/.gradle", + "android/.kotlin", + "ios/build", + "ios/Pods" + ] +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1629eae --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,509 @@ +# BankCBS — Mobile Application Architecture + +A React Native mobile banking client for a Core Banking System (CBS) backend. It +serves **two distinct applications from one binary** — a retail customer app and +a field-agent app — selected at runtime by the authenticated user's role, with +every navigable route, tab and control individually switchable through a +hierarchical feature-flag engine. + +| | | +|---|---| +| **Package** | `bankcbs` v1.0.0 (`com.bankcbs.mobile`) | +| **Runtime** | React Native 0.83.4, React 19.2, TypeScript 5.9 (`strict`, `noUncheckedIndexedAccess`) | +| **State** | Redux Toolkit 2.11 + RTK Query, `react-redux` 9.2 | +| **Navigation** | React Navigation 7 (native-stack + bottom-tabs) | +| **Transport** | Axios 1.14 wrapped by a custom RTK Query `baseQuery` | +| **Crypto / secrets** | `node-forge` (RSA-OAEP), `react-native-keychain`, `react-native-biometrics` | +| **Scale** | 381 source files · ~58,300 LOC · 103 screens · 17 modules · 182 feature keys | + +--- + +## 1. Layering + +Dependencies flow strictly downward. Nothing in a lower layer imports from a +higher one. + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ App.tsx / index.js composition root, provider order │ +├───────────────────────────────────────────────────────────────────┤ +│ src/navigation/ role routing + feature-gated routes │ +├───────────────────────────────────────────────────────────────────┤ +│ src/modules// 17 self-contained domain modules │ +│ screens · components · hooks · store · services · types · utils│ +├───────────────────────────────────────────────────────────────────┤ +│ src/features/ feature-flag engine (cross-cutting) │ +│ src/components/ 16 shared presentational primitives │ +│ src/theme/ design tokens + ThemeProvider │ +│ src/hooks/ app-wide hooks (bootstrap, session…) │ +├───────────────────────────────────────────────────────────────────┤ +│ src/services/ api · security · storage · device … │ +│ src/app/ store, rootReducer, typed hooks │ +│ src/config/ constants/ types/ utils/ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### Directory map + +| Path | Responsibility | +|---|---| +| [src/app/](src/app/) | Store configuration, `rootReducer`, typed `useAppDispatch`/`useAppSelector` | +| [src/navigation/](src/navigation/) | Root/tab/stack navigators, param lists, route→feature maps | +| [src/modules/](src/modules/) | Domain modules (see §7) | +| [src/features/](src/features/) | Feature-flag service, provider, gates, hooks | +| [src/services/api/](src/services/api/) | Axios client, interceptors, endpoint registry, error catalogue | +| [src/services/security/](src/services/security/) | Session manager, device identity, MPIN crypto, biometrics | +| [src/services/storage/](src/services/storage/) | Keychain/AsyncStorage-backed secure storage | +| [src/components/](src/components/) | Shared UI primitives, barrel-exported | +| [src/theme/](src/theme/) | `FintechTheme` token contract + context | +| [src/constants/](src/constants/) | Feature keys, route names, RTK Query tags, translations, fixtures | +| [src/types/](src/types/) | `api.ts` (client errors), `app.ts` (domain), `cbs.ts` (wire types) | + +--- + +## 2. Composition root + +[App.tsx](App.tsx) fixes the provider order, and the order is load-bearing: + +``` +GestureHandlerRootView +└── SafeAreaProvider + └── redux Provider ← store must exist before features read session + └── FeatureProvider ← flags must resolve before navigators register routes + └── ThemeProvider + └── AppShell + ├── useAppBootstrap() + └── ActivityTracker (touch capture → session keep-alive) + ├── RootNavigator + └── ToastHost +``` + +[index.js](index.js) handles three pre-React concerns: + +1. `react-native-get-random-values` is imported **first** — `uuid` binds + `crypto.getRandomValues` at module load, and both the device ID and the MPIN + nonce import `uuid`, so a later import throws at startup. +2. `notifee.onBackgroundEvent` is registered (required by Notifee even as a no-op). +3. `Text` / `TextInput` `defaultProps` are patched to the `Nunito-Regular` font family. + +### Bootstrap sequence + +[src/hooks/useAppBootstrap.ts](src/hooks/useAppBootstrap.ts) runs once on mount: + +1. `bootstrapAuthSession()` — mints the device ID and **validates any stored + session against the server** before trusting it. +2. `seedAllData()` — populates the fixture-backed modules (profile, dashboard, + beneficiaries, payees, deposits, service requests, loans, insurance, cards). +3. `markAppReady()`, then `registerForPushNotifications()`. +4. Registers a global unauthorized handler that dispatches `logoutSession()` and + raises a toast. + +It also re-seeds on every sign-in transition, because logout wipes the entire +Redux tree (§4), and on sign-*out* re-reads only the sign-in context — a full +bootstrap would raise `isHydrating` and blank the app behind a loader on the way +out. + +--- + +## 3. Navigation architecture + +``` +RootNavigator ← isHydrating? Loader : isAuthenticated? +├── AuthNavigator (unauthenticated) +│ Login · Register · VerifyOtp · CreateMpin · MpinLocked +├── MainTabNavigator (role: 'User') +│ ├── Home → HomeNavigator (+ nested LoanFlow, InsuranceFlow) +│ ├── Payments → PaymentsNavigator +│ ├── Services → ServicesNavigator (Hub; tabPress resets the stack) +│ ├── Cards → CardsNavigator +│ └── Profile → ProfileNavigator (lives in src/modules/profile/navigation/) +└── AgentNavigator (role: 'Agent') + ├── AgentDashboard · AgentCustomers · AgentCollections + └── AgentLoans · AgentProfile (each a nested native stack) +``` + +Three deliberate decisions are documented in the code: + +- **Role switch, not a route.** [RootNavigator.tsx](src/navigation/RootNavigator.tsx) + picks the app stack from `auth.role`. Disabling `FeatureKey.AGENT` retires the + whole field-agent app; an already-signed-in agent falls back to the customer + app rather than landing on an empty navigator. +- **Initial route computed, never navigated to.** + [AuthNavigator.tsx](src/navigation/AuthNavigator.tsx) derives + `initialRouteName` from a stored profile instead of `replace()`-ing in an + effect — an effect-driven replace races navigation-container readiness and is + silently dropped, and the warning that would reveal it is stripped from + release builds. `signInContextLoaded` gates this so a null profile means + "unknown" rather than "never registered" before the Keystore has been read. +- **`CreateMpin` and `MpinLocked` disable the back gesture.** The setup token is + single-use; leaving halfway means restarting registration. + +### Route-level feature gating + +[src/navigation/featureRoutes.ts](src/navigation/featureRoutes.ts) maps every +route name to the `FeatureKey` that owns it, and each navigator wraps its +`Screen` in `canRoute(...)`: + +```tsx +const canRoute = useRouteGuard(CARDS_ROUTE_FEATURES); +{canRoute('CardDetails') && } +``` + +A disabled route is **never registered**, so it cannot be reached by +`navigate()`, a deep link, or restored navigation state. Hiding the menu entry +alone would not achieve that. +[FeatureRoute.tsx](src/navigation/FeatureRoute.tsx) is a secondary screen-level +guard for defence in depth. + +--- + +## 4. State management + +[src/app/store.ts](src/app/store.ts) · [src/app/rootReducer.ts](src/app/rootReducer.ts) + +16 domain slices plus two RTK Query reducers (`baseApi`, `schemesApi`). +`serializableCheck` is disabled. + +**Logout wipes everything.** `rootReducer` intercepts +`auth/logoutSession/fulfilled` and sets `state = undefined`, so no slice can +leak data across sessions. Re-seeding on sign-in (§2) is the counterpart to this. + +Three coexisting data-access patterns: + +| Pattern | Where | Used by | +|---|---|---| +| `baseApi.injectEndpoints` (RTK Query over Axios) | `modules/*/services/*Api.ts` | agent, cards, deposit, home ×2, insurance, loan, payments, profile, services ×2, transfer | +| `createSlice` + `createAsyncThunk` | `modules/*/store/` | auth, cards, deposit, home, insurance, loan, payments, profile, services, transfer, ui | +| Standalone `createApi` | `modules/schemes/services/schemesApi.ts` | schemes only — its own reducer path, fixture `queryFn`s | + +Cache invalidation runs on a central tag list — +[src/constants/queryTags.ts](src/constants/queryTags.ts), 25 tags — registered on +`baseApi` so any module can invalidate another's cache. + +--- + +## 5. Data & API layer + +### The envelope + +Every CBS response shares one wrapper ([src/types/cbs.ts](src/types/cbs.ts)): + +```ts +interface CbsEnvelope { + success: boolean; message: string; + data?: T; // present on success; VALIDATION_ERROR carries a field map here + errorCode?: string; // only on failure — this is what the app switches on + timestamp: string; +} +``` + +### Interceptors + +[src/services/api/interceptors.ts](src/services/api/interceptors.ts) is where the +contract is enforced: + +- **Request:** attaches `Authorization: Bearer ` from `secureStorage`. +- **Response:** branches on `envelope.success`, **not the HTTP status** — a + failure can arrive with a 2xx, and treating it as a payload hands callers + `undefined`. Failures are thrown as a `CbsApiError`; successes are unwrapped so + callers read `response.data` as the payload. +- **Session mirroring:** any call carrying an `Authorization` header calls + `sessionManager.touch()`, because that same call reset the server's idle timer. +- **Session loss:** only `SESSION_INVALID` clears storage and fires the global + unauthorized handlers. `INVALID_CREDENTIALS` shares the 401 but means a wrong + MPIN on the sign-in form — bouncing the user out of a screen they aren't in yet + would be nonsense. + +### Error catalogue + +[src/services/api/cbsErrors.ts](src/services/api/cbsErrors.ts) enumerates the +**31 backend error codes** as a `const` tuple, plus 3 client-synthesised codes +(`NETWORK_ERROR`, `REQUEST_TIMEOUT`, `UNKNOWN_ERROR`). Each has authored +`{ title, description, retryable }` copy, so no failure can render as a blank +toast — asserted by [cbsErrors.test.ts](src/services/api/cbsErrors.test.ts). +`errorCode` is treated as the stable contract; `message` is assumed re-wordable +server-side, so no branch reads the message or the status. + +### Endpoint registry & mock strategy + +All paths live in one object, +[src/services/api/endpoints.ts](src/services/api/endpoints.ts), with +parameterised routes as functions. + +`resolveWithMock` in +[serviceHelpers.ts](src/services/api/serviceHelpers.ts) returns fixtures when +`appConfig.features.useMockServer` is set. Per its own contract, endpoints the +CBS API actually implements **must not** go through it — they call `apiClient` +directly so failures surface rather than being papered over with demo data. +Live today: registration, sign-in, accounts/MMID, payees. Fixture-backed: +dashboard, cards, loans, insurance, deposits, service requests, the agent app. + +--- + +## 6. Feature-flag engine + +The most distinctive part of the architecture: **182 keys** covering every +module, screen, tab, menu row, button and action. + +### Keys encode the hierarchy + +[src/constants/featureKeys.ts](src/constants/featureKeys.ts) declares a +dot-notation enum; the service derives parent/child from the path itself, so the +tree can never drift from the naming. + +``` +loans → loans.apply → loans.apply.submit +``` + +[featureConfig.ts](src/features/featureConfig.ts) is a flat +`Record` **on purpose** — the `Record` makes +TypeScript reject the file if any declared key lacks config, which a nested +object literal cannot guarantee. + +### Resolution + +[featureService.ts](src/features/featureService.ts) — one instance, holding a +registry, a layer stack, a resolution cache, and a listener set: + +``` +default → environment → remote → tenant → user (lowest → highest priority) +``` + +A feature is enabled only if it exists, its own flag is on, **and every ancestor +resolves enabled**. Resolution is **fail-closed**: an unknown key, or a key with +missing config, is `false` — in a banking app an unrecognised flag must hide the +feature, never reveal it. `validate()` runs in `__DEV__` and reports +`missing-config`, `unknown-key` and `missing-parent` issues, catching a typo like +`loans.aply.submit` that would otherwise become a root feature no parent could +switch off. + +### Consumption surface + +| API | Use | +|---|---| +| `useFeature(key)` / `useFeatures([...])` | reactive boolean(s); subscribes via `useSyncExternalStore` on a version counter | +| `` | renders children or **nothing** — deliberately no `fallback` prop, so a disabled feature leaves no trace: no greyed control, no "coming soon", no hidden-but-present node | +| `` | JSON-driven UI where the key arrives as data | +| `useFeatureAction` / `guardFeature` | guards a *handler* some other path may still hold | +| `useRouteGuard(map)` | navigator-level route registration (§3) | +| `FeatureProvider` | resolves async remote/tenant layers at startup, exposes `ready` | + +`FeatureProvider` swallows loader failures so the app always falls back to +compiled-in defaults rather than blocking startup. Remote and tenant loaders are +optional props — nothing is wired to Firebase Remote Config or a config API yet, +but the seam exists without touching a single UI component. + +--- + +## 7. Module anatomy + +Every module under [src/modules/](src/modules/) follows the same internal shape, +so any module is navigable once one of them is: + +``` +modules// +├── screens/ route components +├── components/ domain-specific presentational pieces +├── hooks/ form/flow logic extracted from screens +├── store/ Slice.ts · Thunk.ts · Selectors.ts +├── services/ Api.ts (baseApi.injectEndpoints) +├── types/ domain types +├── utils/ calculators, validators, fixtures +├── constants/ domain constants (agent, awareness, schemes) +└── navigation/ only where the module owns its own navigator (agent, profile) +``` + +| Module | Screens | Components | Store | Services | Hooks | Files | +|---|---:|---:|---:|---:|---:|---:| +| agent | 18 | 9 | 2 | 1 | 5 | 41 | +| services | 13 | 6 | 3 | 2 | 3 | 30 | +| profile | 12 | 5 | 3 | 1 | 1 | 25 | +| insurance | 10 | 9 | 3 | 2 | 2 | 29 | +| loan | 10 | 11 | 3 | 1 | 4 | 33 | +| home | 7 | 7 | 3 | 2 | 1 | 22 | +| payments | 7 | 1 | 4 | 1 | 1 | 17 | +| schemes | 7 | 5 | 2 | 1 | 1 | 18 | +| auth | 5 | 7 | 3 | 1 | 1 | 19 | +| deposit | 5 | 4 | 3 | 1 | 2 | 19 | +| cards | 4 | 9 | 3 | 1 | 2 | 21 | +| awareness | 2 | 1 | — | — | 1 | 6 | +| notifications | 1 | — | 1 | — | — | 2 | +| search | 1 | — | — | — | — | 2 | +| employee | 1 | — | — | — | — | 1 | +| transfer | — | 1 | 3 | 1 | 1 | 8 | +| ui | — | — | 3 | — | 1 | 4 | + +`transfer` is state-and-service only (its screens live under `payments`); `ui` +holds cross-cutting UI state (toasts, app-ready, session-expired). + +--- + +## 8. Security & session architecture + +### MPIN never leaves the device in the clear + +[mpinCrypto.ts](src/services/security/mpinCrypto.ts) encrypts +`{ mpin, nonce, ts }` under the server's RSA public key (**RSA-OAEP, SHA-256, +MGF1 explicitly SHA-256**) and sends it as `encryptedMpin`. Three integration +hazards are called out in the source, all of which fail silently: + +- MGF1 **must** be passed explicitly. node-forge happens to default it to `md`, + but Java's `OAEPWithSHA-256AndMGF1Padding` leaves MGF1 on SHA-1 — relying on a + default produces ciphertext the server cannot decrypt, with a useless error. +- `ts` is in **seconds**. Milliseconds read as ~55,000 years ahead and are rejected. +- `nonce` must be fresh per call or it is refused as a replay. + +The key is fetched from `/crypto/public-key` and cached; `PINNED_PUBLIC_KEY` is a +fallback for when the fetch fails. `invalidate()` drops the cache after a +`MALFORMED_REQUEST`, which is what a stale key looks like client-side (the server +cannot say "your key is wrong" without leaking whether the profile exists). + +### Device binding + +[deviceIdentity.ts](src/services/security/deviceIdentity.ts) mints one UUID on +first launch, persisted to the Keychain under +`ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` (not `WHEN_UNLOCKED_*` — the ID +is not a secret and sign-in must read it *before* the user authenticates). It is +memoised in memory, and concurrent cold-start callers share one in-flight promise +— otherwise each would mint a UUID, last write wins, and the device silently +unbinds. The registered profile is bound to this value; a mismatch returns +`DEVICE_NOT_BOUND`, which reads like a server fault but is a client bug. + +### Secure storage + +[secureStorage.ts](src/services/storage/secureStorage.ts) keeps three Keychain +services — session, last profile, biometric MPIN — plus a biometric-enabled flag +in AsyncStorage (the device ID is a fourth, owned by `deviceIdentity`). The +session is cached in memory because the request interceptor needs the token on +*every* call and an Android Keystore read costs tens of ms. + +The MPIN is retained **only** when the customer opts into biometric sign-in: the +API has no refresh token, so a session can only be opened by encrypting the MPIN, +which makes biometric sign-in impossible otherwise. It sits behind the device's +own biometric prompt, never in AsyncStorage. An unparseable session (written by +an older build) is treated as absent rather than crashing bootstrap. + +### Session lifetime — three independent triggers + +[sessionManager.ts](src/services/security/sessionManager.ts) adopts the server's +own limits at sign-in (`idleTimeoutSeconds`, `expiresAt`) so the app never +guesses and never drifts from what the backend enforces. It tracks **two clocks +separately**: + +- `lastServerContactAt` — the only thing the server's idle window is measured + from, so the only thing expiry may be judged on. +- `lastInteractionAt` — screen touches. Because most of the app reads from + fixtures, a user can tap around for five minutes without one authenticated + call; treating that as idle would sign them out mid-use. So interaction drives + a **keep-alive**, never the expiry clock itself. + +[useSessionTimeout.ts](src/hooks/useSessionTimeout.ts) polls every 5s and expires +on whichever of three triggers fires first — `idle`, `absolute`, or `background` +(returning to the foreground after `lockAfterBackgroundMs`, default 30s). Each +has its own copy; `background` is toned as informational because a deliberate +re-lock is not a failure, and dressing it up as one would train users to ignore +the banner that matters. + +[ActivityTracker](src/components/ActivityTracker/) wraps the whole tree and reads +touches via `onStartShouldSetResponderCapture`, returning `false` so it sees every +touch on the way down but never becomes the responder — a wrapper that actually +captured touches would break every pressable beneath it. + +`appConfig.security.requireMpinOnLaunch` defaults to **true**: every launch asks +for the MPIN rather than resuming a stored session, because there is no local +MPIN verifier — the only one is `POST /auth/login`, so "ask again" necessarily +means "open a fresh session". + +--- + +## 9. Presentation layer + +**Theme.** [src/theme/](src/theme/) defines a `FintechTheme` token contract — +colours (including a "Cyber Nexus" accent set), spacing, typography scale, radii, +shadows — consumed via `useTheme()`. `RootNavigator` maps these onto React +Navigation's `DarkTheme`. + +**Shared primitives.** 16 barrel-exported components in +[src/components/](src/components/): `Button`, `Input`, `Card`, `Modal`, +`BottomSheet`, `DatePicker`, `Loader`, `EmptyState`, `BalanceCard`, +`TransactionItem`, `AppHeader`, `ToastHost`, `ActivityTracker`, plus three +keyboard/layout containers. + +**`ScreenShell`** is the standard screen wrapper — gradient background, safe +area, `AppHeader`, optional scroll, per-platform `KeyboardAvoidingView` behaviour +(`padding` on iOS, `height` on Android), `withTabBar` padding so content never +hides behind the absolutely-positioned tab bar, and a `fixedFooter` slot. + +**i18n.** [useTranslation.ts](src/hooks/useTranslation.ts) resolves keys against +[translations.ts](src/constants/translations.ts) using the language in the profile +slice, falling back to `en` then to the key itself. Currently a small dictionary +rather than a full i18n library. + +**Notifications.** +[pushNotifications.ts](src/services/notifications/pushNotifications.ts) (Notifee) +for delivery; the `notifications` slice holds the in-app inbox. + +--- + +## 10. Build & platform + +- **Android permissions:** `INTERNET`, fine/coarse location (branch locator, + agent routes), `POST_NOTIFICATIONS`, `READ_PHONE_STATE` / `READ_PHONE_NUMBERS` + (SIM-card checks in [simCard.ts](src/services/device/simCard.ts)). +- **Metro** ([metro.config.js](metro.config.js)) block-lists native build output + — `android/app/build` alone exceeds 2 GB after a debug build, and without + watchman Metro walks it on startup and never becomes responsive. +- **TypeScript** is `strict` with `noUncheckedIndexedAccess` and `bundler` module + resolution. +- **Scripts:** `start`, `android`, `ios`, `lint`, `test`, `typecheck`, + `clean:android`. +- **Notable native deps:** MapLibre (branch locator / agent routes), Reanimated 4 + + Worklets, gesture-handler, `@gorhom/bottom-sheet`, SVG, linear-gradient, + haptic feedback, date-picker. + +--- + +## 11. Testing + +Jest, with 5 unit suites concentrated on the logic where a silent error is +costly: + +| Suite | Covers | +|---|---| +| [featureService.test.ts](src/features/featureService.test.ts) | flag resolution, hierarchy, layer precedence, fail-closed behaviour | +| [cbsErrors.test.ts](src/services/api/cbsErrors.test.ts) | all 31 codes present and unique, copy for every code, retryability, `CbsApiError` identity | +| [validation.test.ts](src/utils/validation.test.ts) | shared input validators | +| [emi.test.ts](src/modules/loan/utils/emi.test.ts) | loan EMI maths | +| [depositCalculators.test.ts](src/modules/deposit/utils/depositCalculators.test.ts) | FD/RD maturity maths | + +There is no component, navigation or integration test layer. + +--- + +## 12. Current state & known gaps + +Factual observations from the code, not recommendations: + +1. **`useMockServer` is `true`** in [src/config/env.ts](src/config/env.ts). + Registration, sign-in, accounts/MMID and payees hit the live API; the other + modules resolve from ~1,390 lines of fixtures in + [src/constants/mockData.ts](src/constants/mockData.ts). +2. **`API_ORIGIN` is a hard-coded LAN address** (`http://10.209.40.168:8089`) over + plain HTTP. The file documents that debug builds permit cleartext and release + builds will need HTTPS or a network-security-config. There is no + per-environment config mechanism. +3. **`PINNED_PUBLIC_KEY` is committed in source** as the fallback when the key + fetch fails. +4. **`schemesApi` bypasses the shared stack** — its own `createApi` with + `fetchBaseQuery({ baseUrl: '/' })` and fixture `queryFn`s, including a + hard-coded `otp !== '123456'` check. It does not use `baseApi`, the endpoint + registry, or the CBS error catalogue. +5. **Remote and tenant flag layers are unwired.** `ENVIRONMENT_FEATURE_OVERRIDES` + is `{}` and `FeatureProvider` is mounted without loader props, so all 182 keys + resolve from the compiled-in defaults. The layering exists as a seam. +6. **Three data-access patterns coexist** (RTK Query, slice+thunk, standalone + `createApi`), several modules using two of them at once. +7. **`serializableCheck` is disabled** store-wide. +8. **The `employee` module is a single placeholder screen**; `EmployeeNavigator` + is defined but not reachable from `RootNavigator`, which routes only `'Agent'` + and `'User'`. diff --git a/App.tsx b/App.tsx index b8faa86..bc33884 100644 --- a/App.tsx +++ b/App.tsx @@ -7,19 +7,30 @@ import { Provider } from 'react-redux'; import { store } from './src/app/store'; import { RootNavigator } from './src/navigation/RootNavigator'; -import { ThemeProvider } from './src/theme'; +import { ThemeProvider, useTheme } from './src/theme'; import { ToastHost } from './src/components/ToastHost'; +import { ActivityTracker } from './src/components/ActivityTracker'; +import { FeatureProvider, hydrateFeatures } from './src/features'; import { useAppBootstrap } from './src/hooks/useAppBootstrap'; const AppShell = () => { + const theme = useTheme(); + useAppBootstrap(); return ( - <> - + + {/* + The app-wide default. Individual screens override it — a screen on the + full-bleed navy wash forces light icons, because that wash is dark in + both themes — but everything else sits on `colors.background`, and + this follows the live theme so dark mode does not leave black icons on + a near-black bar. + */} + - + ); }; @@ -28,9 +39,15 @@ export default function App() { - - - + {/* Reads the persisted local overrides and the bank's server-driven + config before the navigators mount — they decide at mount time + which screens to register, so a flag arriving later would leave a + disabled route reachable until the next launch. */} + + + + + diff --git a/STITCH_PROMPT.md b/STITCH_PROMPT.md new file mode 100644 index 0000000..350c5bb --- /dev/null +++ b/STITCH_PROMPT.md @@ -0,0 +1,312 @@ +# Stitch design-system prompt — BankCBS + +Paste **Part A** into Stitch to establish the design system, then prompt individual +screens against it. Part B is a short fallback if Stitch truncates your input. +Part C is what to send back to me. + +--- + +## Part A — the system prompt (paste this) + +``` +You are designing the design system for BankCBS, a production mobile banking app +built in React Native for the Indian market. Do not design individual screens yet. +Produce the system: colour, typography, spacing, elevation, and a component +library that screens will be assembled from. + +── THE PRODUCT ────────────────────────────────────────────────────────────── +One app serving two very different audiences from the same binary: + +1. RETAIL CUSTOMER. Checks balances, pays people, manages cards, opens fixed and + recurring deposits, applies for loans and insurance. Uses the app for two + minutes at a time, often on a mid-range Android phone, and needs to trust it + with money. Tone: calm, precise, unmistakably a bank — not a fintech toy. + +2. FIELD COLLECTION AGENT. A bank employee visiting customers door to door in + daylight, one-handed, at speed, entering cash collections and opening + accounts. Needs density, high contrast, large tap targets, and numbers + readable in direct sunlight. This is a working tool, not a lifestyle app. + +Design one coherent system that serves both. The agent surfaces should read as +the same family — same primary colour, same components — but denser and higher +contrast. Show me how the system flexes between the two. + +── KEEP THIS ──────────────────────────────────────────────────────────────── +PRIMARY BRAND NAVY: #124577. This is locked. It is the bank's identity and must +remain the dominant brand colour, used for primary actions, active states, and +brand moments. Build the rest of the palette around it. + +── REDESIGN EVERYTHING ELSE ───────────────────────────────────────────────── +You have full latitude, and I want you to use it, on: + - the accent / secondary colour (the current #10B981 green is not working) + - semantic colours for success, warning, danger, info + - the full neutral ramp (backgrounds, surfaces, borders, three text tiers) + - the type scale, weights and line heights + - the spacing scale + - corner radii + - elevation and depth + - how cards, rows and sections separate from one another + +The current app looks like an unstyled admin form: flat white cards, a weak grey +ramp, no hierarchy, no considered depth. Fix that. It should look like a bank a +person would trust with their salary. + +── HARD TECHNICAL CONSTRAINTS (React Native — not a browser) ──────────────── +This ships as React Native. Anything below is UNBUILDABLE and must not appear in +your design. This is the most important section of this brief. + +DO NOT USE: + - Frosted glass, backdrop blur, or any blur effect. No blur library is + installed. Every "glassmorphism" surface must instead be a solid or + low-opacity flat fill. + - CSS grid. Layout is flexbox only. + - Pseudo-elements (::before / ::after) for decoration, arrows, or dividers. + Anything visible must be a real element. + - Text gradients or gradient-filled type. Text takes one flat colour. + - Radial, conic, or mesh gradients. LINEAR gradients only (two or three stops). + - Inner shadows, multiple stacked shadows, or coloured glows around elements. + React Native supports ONE outer shadow per element: colour, x/y offset, + opacity, radius on iOS; a single flat elevation value on Android. Android + ignores shadow colour and offset entirely — so any design that depends on a + soft coloured shadow will look flat on most of the user base. Design depth + with borders, surface-tone steps, and generous spacing instead. + - Border gradients, dashed borders with custom dash patterns, or per-corner + different border widths. + - CSS filters, blend modes, or opacity masks. + - position: sticky, and any scroll-linked parallax or scroll-driven colour + change. + - SVG masks or clip paths beyond a simple rounded rectangle. + - Custom checkbox/radio/switch shapes that depend on pseudo-elements. + +DO USE, all available: + - Linear gradients (react-native-linear-gradient) + - Inline SVG shapes and charts (react-native-svg) + - Spring and timing animation, gesture-driven motion (Reanimated) + - Haptic feedback on confirmations + - Native bottom sheets with snap points + - Native date pickers + +── TYPOGRAPHY — READ THIS CAREFULLY ───────────────────────────────────────── +The ONLY font family available is Nunito, and only these four weights exist as +real font files: + + 400 Regular 600 SemiBold 700 Bold 800 ExtraBold + +There is NO 300, NO 500, and NO 900. React Native on Android does not synthesise +missing weights — it silently falls back and the text renders wrong. So specify +every single text style using only 400, 600, 700 or 800. If your type scale wants +a light or medium weight, redesign it to work within these four. + +Nunito is a rounded humanist sans. It is friendly, which fights slightly with +banking authority — show me how you handle that with weight, size, tracking and +case rather than by swapping the family. + +Give me a complete scale with a name, size (px), weight, line height and letter +spacing for each role. Include at minimum: a display/balance figure, three +headings, body, body-small, caption, an uppercase label/eyebrow, a button label, +and a tabular numeric style for money in tables. + +── MONEY AND NUMBERS ──────────────────────────────────────────────────────── +This is an Indian bank. Get these right, they are not cosmetic: + - Currency is the rupee: ₹ + - Indian digit grouping, NOT thousands grouping. ₹1,25,000 and ₹12,50,000 — + two digits, two digits, then three. Never ₹125,000. + - Large sums are spoken in lakh and crore. Show how you'd abbreviate: ₹1.25 L, + ₹2.4 Cr. + - Money must align on the decimal in any list or table. Specify a numeric style + with fixed-width digits. + - Show a positive (credit) and negative (debit) treatment. Do not rely on colour + alone — a sign or direction icon must carry it too. + +Other formats that appear throughout, design real slots for them: + - Mobile number: +91 followed by exactly 10 digits, displayed 98765 43210 + - Account number, always masked except the last four: A/c ••••4321 + - IFSC code: 11 characters, letters and digits, e.g. HDFC0001234 + - MPIN: exactly 4 digits, always obscured, entered in separate boxes + - OTP: exactly 6 digits, entered in separate boxes + - Transfer rails, shown as labels the customer chooses between: IMPS (instant, + 24x7), NEFT (batched), RTGS (high value, ₹2,00,000 minimum), and intra-bank + - Dates as 12 Mar 2026, never 03/12/2026 — ambiguous between conventions + +── COMPONENTS TO SPECIFY ──────────────────────────────────────────────────── +These already exist in the codebase and your system must define how each one +looks. Keep the names. + + Button variants: primary, secondary, ghost, danger. + Full-width and inline. With and without a leading icon. + Input single-line and multi-line. Has an uppercase label above, an + optional leading accessory (icon, or a "+91" prefix with a + divider rule), an optional trailing accessory (validation tick, + clear, reveal), and error text below. + CodeInput separate boxes for MPIN (4) and OTP (6). + Card the base surface everything sits on. + BalanceCard the hero object: account nickname, masked number, large + balance figure, a show/hide balance control, account holder. + Appears in a horizontal swipe carousel with multiple accounts. + TransactionItem a row: direction icon, counterparty, date, amount, status. + AppHeader screen title, optional back button, optional right slot. + ScreenShell the page frame: background, safe area, optional scroll, + optional pinned footer. + BottomSheet for pickers and confirmations. + Modal for blocking confirmations. + DatePicker date selection. + Loader full-screen busy state. + EmptyState icon, title, description, optional action button. + Toast transient feedback, top of screen. success / error / info. + +Also design these, which the app needs and does not yet have: + StatusPill account, transaction, loan and card status. At least: active, + pending, blocked, frozen, failed, matured, overdue. + SectionHeader a titled group divider with an optional trailing action. + ListRow the generic settings/menu row: leading icon, title, optional + subtitle, and a trailing chevron, value, badge or switch. + Stepper progress through a multi-step flow (registration is 3 steps). + Skeleton loading placeholder for cards, rows and the balance figure. + SegmentedControl for switching between two or three views. + AmountInput large, keypad-driven money entry — the centre of any payment. + InfoBanner inline informational, warning and error notices. + Chip selectable filter chips, single and multi-select. + KeyValueRow the label/value pair used all over detail and receipt screens. + +── EVERY COMPONENT NEEDS EVERY STATE ──────────────────────────────────────── +A banking app spends most of its life in non-default states. For each component, +show: default, pressed, focused, disabled, loading, error, selected, and where +relevant empty and skeleton. Designs that only show the happy path are the single +most common reason a handoff fails, so treat the states as the deliverable rather +than an appendix. + +── LAYOUT AND PLATFORM RULES ──────────────────────────────────────────────── + - Design for a 360–412pt wide viewport. Mid-range Android is the common case. + - The bottom tab bar is 90pt tall and floats above content. Any scrolling page + must reserve about 106pt of bottom padding so its last row is not trapped + behind it. + - The status bar is translucent and content passes under it. Any dark full-width + header must therefore work with light status-bar icons, and any light one with + dark icons — tell me which each of your surfaces requires. + - Primary actions live in a footer pinned above the keyboard, not at the end of + a long scroll. + - Minimum touch target 44x44pt. For agent screens, 48pt. + - Every screen must survive the keyboard being open over a text field. + - Design light theme as the primary. Note where a dark theme would need + different values, but do not design it fully. + +── ACCESSIBILITY ──────────────────────────────────────────────────────────── + - Body text and any figure showing money must reach WCAG AA (4.5:1). State the + contrast ratio for each text-on-surface pairing you define. + - Agent screens are used in direct sunlight; hold them above 7:1. + - Never encode meaning in colour alone — pair it with an icon, a sign, or text. + - The layout must not break when the system font scales up 130%. + +── DO NOT MAKE IT LOOK AI-GENERATED ───────────────────────────────────────── +Avoid, specifically: purple-to-blue gradient heroes; frosted translucent cards +floating on blurred blobs; a single acid-green or violet accent on near-black; +one uniform corner radius and one uniform shadow stamped on every element, which +flattens all hierarchy; emoji as iconography; giant centred hero text on every +screen; and cards for things that are not objects. Spend visual weight where the +information matters — the balance, the amount being sent, the thing needing +attention — and keep everything around it quiet. + +Icons come from the Ionicons set. Name the specific Ionicons you intend +(shield-checkmark, chevron-forward, wallet-outline, swap-horizontal, and so on) +and note where you mean the filled versus outline variant. + +── WHAT TO DELIVER ────────────────────────────────────────────────────────── +1. A token table. Every colour as a hex value with a semantic name and its + intended use. Every spacing step, radius, and shadow as a number. +2. The type scale as a table: name, px size, weight (400/600/700/800 only), + line height, letter spacing. +3. Text-on-surface contrast ratios for every pairing. +4. A component sheet per component above, showing all its states, annotated with + the tokens used — padding, radius, height, border width, text style. +5. Two side-by-side sample compositions proving the system flexes: one calm + customer screen and one dense agent screen. +6. A short list flagging anything in your design that relies on an effect the + constraints above rule out, and what you substituted instead. +``` + +--- + +## Part B — condensed version + +Use this only if Stitch truncates Part A. It keeps the constraints that cause +real rework and drops the explanation. + +``` +Design system for an Indian mobile banking app in React Native. Two audiences: +retail customers (calm, trustworthy, premium) and field collection agents (dense, +high contrast, sunlight-readable, 48pt targets). One coherent system serving both. + +KEEP primary brand navy #124577 as the dominant colour. REDESIGN the accent, +semantic colours, neutral ramp, type scale, spacing, radii and elevation — the +current design is flat and has no hierarchy. + +FONT: Nunito only, and only weights 400, 600, 700, 800 exist. No 300, no 500, +no 900 — Android will not synthesise them. Every text style must use one of those +four. + +UNBUILDABLE, do not use: blur or frosted glass (no blur library installed), CSS +grid, pseudo-elements, text gradients, radial/conic gradients, inner or multiple +or coloured shadows (React Native allows one outer shadow; Android ignores its +colour and offset entirely), blend modes, filters, position:sticky, clip paths. +Available: linear gradients, SVG, spring animation, haptics, bottom sheets. + +INDIAN FORMATS: ₹ with lakh grouping (₹1,25,000 never ₹125,000), lakh/crore +abbreviations, +91 and 10-digit mobile shown as 98765 43210, masked accounts +(A/c ••••4321), 11-char IFSC, 4-digit MPIN and 6-digit OTP in separate boxes, +IMPS/NEFT/RTGS transfer rails, dates as 12 Mar 2026. + +COMPONENTS: Button (primary/secondary/ghost/danger), Input (uppercase label, +leading +91 prefix or icon, trailing tick, error text), CodeInput, Card, +BalanceCard (masked number, large balance, hide toggle, in a carousel), +TransactionItem, AppHeader, ScreenShell, BottomSheet, Modal, Loader, EmptyState, +Toast, StatusPill (active/pending/blocked/frozen/failed/overdue), ListRow, +SectionHeader, Stepper, Skeleton, SegmentedControl, AmountInput, InfoBanner, +Chip, KeyValueRow. + +Show EVERY component in default, pressed, disabled, loading, error, selected, +empty and skeleton states — the states are the deliverable, not an appendix. + +LAYOUT: 360–412pt wide. Bottom tab bar is 90pt and floats, so reserve ~106pt +bottom padding. Translucent status bar. Primary action pinned above the keyboard. +44pt minimum targets, 48pt for agents. Light theme primary. + +Body text and money figures at WCAG AA 4.5:1, agent screens above 7:1. Never +colour alone — pair with icon or sign. Icons from Ionicons, name them exactly. + +Avoid the generic AI look: no purple-blue gradient heroes, no frosted cards on +blurred blobs, no single radius and shadow stamped on everything, no emoji icons. + +DELIVER: token table with hex and px values; type scale table; contrast ratios; +a component sheet with all states annotated with tokens; one calm customer screen +and one dense agent screen side by side; and a list of anything you had to +substitute because of the constraints above. +``` + +--- + +## Part C — what to send me back + +Send whichever of these you get. In rough order of usefulness: + +1. **The token table and type scale** — as text if possible, not only as an + image. These become `src/theme/tokens.ts` directly, and exact hex and px + values matter more than any screenshot. +2. **Component sheet screenshots** — one per component, with the states visible. +3. **Stitch's exported code** — paste it or save it into the repo. I'll translate + it to React Native; I do not need it to be RN already. + +When I get the code, expect me to flag some of it. Stitch emits web CSS, and the +things it reaches for most — `backdrop-filter`, `box-shadow` with multiple +layers or a spread radius, `background-clip: text`, `display: grid`, +`::before` decoration — have no React Native equivalent. I'll tell you what I +substituted rather than silently approximating it. + +Two things worth deciding before I wire the tokens in: + +- **Nunito.** The prompt holds you to it because it is what ships in the repo. If + Stitch proposes a type treatment that genuinely needs a different family, say + so and I'll add the font files — it is a small change and better made once than + worked around. +- **The 90pt floating tab bar** is currently hard-coded in both navigators. If + Stitch designs a different navigation shape, that is a real but contained + change to `MainTabNavigator`, `AgentNavigator` and `layoutConstants.ts`. diff --git a/STITCH_PROMPT_LOGIN.md b/STITCH_PROMPT_LOGIN.md new file mode 100644 index 0000000..14e5211 --- /dev/null +++ b/STITCH_PROMPT_LOGIN.md @@ -0,0 +1,198 @@ +# Stitch prompt — Login screen + +Paste the block below into Stitch. It is self-contained: use it on its own, or +after the system prompt in [STITCH_PROMPT.md](STITCH_PROMPT.md) — if you have +already run that one, Stitch will reuse those tokens and you can delete the +CONSTRAINTS section here. + +**No progress indicator.** Stated three times in the prompt, because Stitch adds +step dots to auth screens unprompted. + +--- + +``` +Design the sign-in screen for BankCBS, an Indian mobile banking app built in +React Native. One screen, but it has six distinct states — I need all of them. + +── CRITICAL: WHAT NOT TO ADD ──────────────────────────────────────────────── +NO progress indicator. No step dots, no "Step 1 of 3", no numbered circles, no +segmented progress bar, no breadcrumb. Signing in is a single screen, not a +flow, and any progress ornament on it is wrong. Do not add one anywhere. + +Also do not add: a "Forgot MPIN" link (there is deliberately no reset flow — +see the copy below), a social sign-in row, a "Remember me" checkbox for the +password, a carousel of marketing slides, or a language switcher. + +── HOW SIGNING IN ACTUALLY WORKS ──────────────────────────────────────────── +There is no password. A customer registers a device once, sets a 4-digit MPIN, +and from then on signs in with that MPIN — or with the phone's fingerprint or +face unlock, if they turned it on. The profile is bound to the handset, so a +returning customer does not retype their mobile number. + +Two audiences share this screen, chosen by a tab at the top: + - CUSTOMER — a retail bank customer. Calm, trustworthy, premium. + - AGENT — a bank field-collection employee who visits customers door to door. + Same visual family, but this person is standing in daylight working fast. + +── BRAND ──────────────────────────────────────────────────────────────────── +Primary brand navy #124577 is locked — it is the bank's identity. Use it for +the primary button, active states and brand moments. You choose everything +else: accent colour, semantic colours, the neutral ramp, spacing, radii, +elevation. The current screen is flat white cards on light grey with no +hierarchy; make it look like a bank someone trusts with their salary. + +App name: BankCBS. Mark: a shield with a tick inside (Ionicons +"shield-checkmark"). There is no logo file — design the mark from the icon. + +── THE SIX STATES, ALL REQUIRED ───────────────────────────────────────────── + +STATE 1 — RETURNING CUSTOMER (the common case, design this first) +The device already knows who this is, so there is no mobile-number field. + - An identity card showing: a circular avatar with the customer's first + initial, their full name ("Priya Sharma"), their masked mobile + ("••••••3210"), and a small green verified shield at the trailing edge. + - Below it, a 4-box MPIN entry. Digits are always obscured. Boxes fill left to + right, and the box being typed into is visibly focused. + - Primary full-width button: "Sign In". + - A divider reading "OR SIGN IN WITH", then a single circular 64pt fingerprint + button (Ionicons "finger-print-outline") with the caption "Use Biometrics". + Divider and button appear or disappear together. + - Footer, small: "Not you? Register" — Register is the tappable part. + - Static note, not a link: "Forgot your MPIN? Visit your branch to have it + reset." There is deliberately no in-app reset. Design this so it reads as + helpful information rather than a dead end. + +STATE 2 — NEW DEVICE / AGENT +No stored profile, so the full form shows. + - Field: "REGISTERED MOBILE NUMBER" — an uppercase label above the input, a + "+91" prefix inside the field separated by a thin vertical rule, then 10 + digits displayed as "98765 43210". A tick appears at the trailing edge once + 10 digits are in. + - Then the same 4-box MPIN entry, "Sign In" button, and footer. + - Footer copy here is "New to BankCBS? Register". + +STATE 3 — ROLE TABS +Two tabs above the card: "Customer" and "Agent". The active tab is filled with +the brand navy and connects visually to the card below it, as though the card +and tab are one continuous surface — the inactive tab is not. Show both +positions. Also show the screen with the tab strip absent entirely, because +when only one role is available it is not rendered at all. + +STATE 4 — LOADING + - The whole screen while the app is still reading the stored profile: a + centred spinner, no form. This is the first thing shown on a cold start, so + it must not look broken. + - The "Sign In" button mid-request: a spinner in place of the label, button + disabled, fields locked. + +STATE 5 — ERRORS + - Wrong MPIN: the MPIN boxes clear and show an error treatment, plus a toast + at the top of the screen titled "Sign-in failed" with the body "Check your + mobile number and MPIN, then try again." + - Invalid mobile number, inline under the field: "Enter the 10-digit number + registered with your bank". + - An information toast: "Biometric sign-in cancelled — you can sign in with + your MPIN instead." + Design the toast: it appears at the top, over content, and has success, error + and information variants. + +STATE 6 — MPIN LOCKED (a separate full screen) +Too many wrong attempts. There is no way out inside the app, and the design +must be honest about that without feeling punitive. + - Heading: "MPIN locked" + - Sub: "Too many incorrect attempts" + - Body explaining the MPIN can only be reset at a branch. + - Two buttons: "Contact support" (secondary) and "Back to sign in" (ghost). + +── ONE MORE ELEMENT ───────────────────────────────────────────────────────── +On the customer tab, when the phone supports biometrics but the customer has +not enabled them yet, a row appears above the button: the label "Enable +biometric sign-in next time" and a switch. Show it on and off. + +── HARD TECHNICAL CONSTRAINTS (React Native, not a browser) ───────────────── +Anything here is unbuildable and must not appear in the design: + - No blur, frosted glass or backdrop filter. No blur library is installed. + Translucent surfaces must be flat low-opacity fills. + - No CSS grid — flexbox only. No pseudo-elements (::before / ::after); every + visible thing must be a real element. + - No text gradients. No radial, conic or mesh gradients — linear only, two or + three stops. + - One outer shadow per element, maximum. No inner shadows, no stacked shadows, + no coloured glows. Android ignores shadow colour and offset entirely, so do + not build depth from soft coloured shadows — use borders, surface-tone steps + and spacing. + - No blend modes, filters, clip paths or opacity masks. No position: sticky. + +FONT: Nunito, and only weights 400, 600, 700 and 800 exist as real files. There +is no 300, no 500, no 900 — Android does not synthesise missing weights, it +falls back and renders wrong. Every text style must use one of those four. + +LAYOUT: 360–412pt viewport, mid-range Android is the common case. Translucent +status bar with content passing under it — tell me whether your header needs +light or dark status-bar icons. The MPIN and mobile fields must remain visible +with the keyboard open, and the primary button should sit above the keyboard +rather than at the end of a scroll. Minimum touch target 44x44pt. Light theme +only. + +ACCESSIBILITY: body text at WCAG AA 4.5:1 or better — give me the ratio for +each text-on-surface pairing. Never encode meaning in colour alone. The layout +must survive the system font scaling up 130%. + +Icons come from Ionicons. Name the exact icons you use (shield-checkmark, +finger-print-outline, call-outline, chevron-back, and so on) and say whether +you mean the filled or outline variant. + +── DO NOT MAKE IT LOOK AI-GENERATED ───────────────────────────────────────── +Avoid: purple-to-blue gradient heroes; frosted cards floating over blurred +colour blobs; one corner radius and one shadow stamped on every element, which +flattens all hierarchy; emoji as icons; a full-height centred hero that pushes +the form off screen; and dead vertical space with the button stranded at the +bottom. Spend the visual weight on the identity card and the MPIN entry, and +keep everything else quiet. + +── DELIVER ────────────────────────────────────────────────────────────────── +1. All six states as separate frames. +2. A token table: every colour as a hex with a semantic name, every spacing + step, radius and shadow as a number. +3. The type scale used: name, px size, weight (400/600/700/800 only), line + height, letter spacing. +4. Contrast ratios for each text-on-surface pairing. +5. Annotations on the frames giving the padding, height, radius and border + width of the identity card, the MPIN boxes, the input, the tabs and the + button. +6. Anything you had to substitute because of the constraints above, and what + you used instead. +``` + +--- + +## What to send back + +The token table and type scale **as text** if you can — those become +`src/theme/tokens.ts` directly and exact values matter more than any image. +Then the six frames, and Stitch's exported code if you have it. + +When the code arrives I will flag what cannot cross over. Stitch reaches for +`backdrop-filter`, multi-layer `box-shadow`, `background-clip: text`, +`display: grid` and `::before` decoration, none of which have a React Native +equivalent — I will tell you what I substituted rather than quietly approximating. + +## Two details in the current build worth knowing + +- The active role tab is drawn as an **SVG path with a notched, angled right + edge**, hard-coded to `#143e6c` in + [LoginRoleTabs.tsx](src/modules/auth/components/LoginRoleTabs.tsx) — slightly + off the brand navy. The prompt describes the intent (tab fused to the card) + rather than that shape, so Stitch is free to propose something better. If it + does, that file needs the new geometry and the literal replaced with a token. +- `AppHeader`'s subtitle is commented out app-wide, so `ScreenShell`'s + `subtitle` prop renders nothing. LoginScreen sidesteps this by passing no + title at all and drawing its own hero. Whatever Stitch returns, the heading + has to live in the screen body — or that component needs fixing first. + +## One thing to confirm + +You said you do not want the progress indicator. The Login screen never had +one — the three-segment bar is on the **Register** screen I built earlier +([RegistrationSteps.tsx](src/modules/auth/components/RegistrationSteps.tsx)). +Say the word and I will pull it off Register too; it is a two-line change. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3bc75ee..f019c36 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -6,6 +7,35 @@ + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/android/app/src/main/assets/fonts/Nunito-ExtraBold.ttf b/android/app/src/main/assets/fonts/Nunito-ExtraBold.ttf deleted file mode 100644 index b4c79b7..0000000 --- a/android/app/src/main/assets/fonts/Nunito-ExtraBold.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/android/app/src/main/assets/fonts/Nunito-Regular.ttf b/android/app/src/main/assets/fonts/Nunito-Regular.ttf deleted file mode 100644 index b73048c..0000000 --- a/android/app/src/main/assets/fonts/Nunito-Regular.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/android/app/src/main/assets/fonts/Nunito-SemiBold.ttf b/android/app/src/main/assets/fonts/Nunito-SemiBold.ttf deleted file mode 100644 index 734112e..0000000 --- a/android/app/src/main/assets/fonts/Nunito-SemiBold.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/android/app/src/main/java/com/bankcbs/mobile/MainApplication.kt b/android/app/src/main/java/com/bankcbs/mobile/MainApplication.kt index a67c752..c9a4d03 100644 --- a/android/app/src/main/java/com/bankcbs/mobile/MainApplication.kt +++ b/android/app/src/main/java/com/bankcbs/mobile/MainApplication.kt @@ -6,6 +6,7 @@ import com.facebook.react.ReactApplication import com.facebook.react.ReactHost import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.bankcbs.mobile.sim.SimCardPackage class MainApplication : Application(), ReactApplication { @@ -14,8 +15,8 @@ class MainApplication : Application(), ReactApplication { context = applicationContext, packageList = PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + // Local module, so there is nothing for autolinking to find. + add(SimCardPackage()) }, ) } diff --git a/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardModule.kt b/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardModule.kt new file mode 100644 index 0000000..de23557 --- /dev/null +++ b/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardModule.kt @@ -0,0 +1,256 @@ +package com.bankcbs.mobile.sim + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableArray + +/** + * Reads what the handset can tell us about its SIM, so registration can offer + * the customer's own number instead of asking them to type it. + * + * Worth being blunt about the limits: the MSISDN is only available if the + * carrier actually provisioned it onto the SIM, and most Indian operators do + * not. `getPhoneNumber()` returning blank is the common case, not the error + * case — so this module reports what it found and never pretends. The caller + * must always keep a manual-entry path. + */ +class SimCardModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName() = NAME + + private fun hasPermission(permission: String) = + reactApplicationContext.checkSelfPermission(permission) == + PackageManager.PERMISSION_GRANTED + + /** + * Reduces whatever the platform hands back to the ten significant digits the + * API expects. Carriers return the number in every shape going — `+91…`, + * `0091…`, with spaces — and the backend wants exactly ten digits. + */ + private fun normalise(raw: String?): String? { + val digits = raw?.filter { it.isDigit() } ?: return null + + return when { + digits.length == 10 -> digits + digits.length > 10 -> digits.takeLast(10) + else -> null + } + } + + private fun subscriptionManager() = + reactApplicationContext.getSystemService(SubscriptionManager::class.java) + + /** + * The number provisioned on one specific subscription, or null. + * + * Two paths, because the supported one moved: from API 33 the answer comes + * from `SubscriptionManager.getPhoneNumber(subId)` and needs + * READ_PHONE_NUMBERS; below that the only source is the deprecated + * `SubscriptionInfo.number`, which is blank on most carriers. + */ + private fun numberFor(subscriptions: SubscriptionManager?, info: SubscriptionInfo): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermission(Manifest.permission.READ_PHONE_NUMBERS) + ) { + try { + normalise(subscriptions?.getPhoneNumber(info.subscriptionId))?.let { return it } + } catch (error: SecurityException) { + // Fall through to the legacy field. + } + } + + @Suppress("DEPRECATION") + return try { + normalise(info.number) + } catch (error: SecurityException) { + null + } + } + + /** + * Every active subscription, one entry per populated slot. + * + * This is what lets a dual-SIM customer say which line their bank has, rather + * than the app guessing. It reports each slot even when the number is null — + * the carrier and the slot are still enough for someone to recognise which + * SIM is theirs, which is the whole point of asking. + * + * Requires READ_PHONE_STATE; without it the list is empty rather than an + * error, and the caller falls back to manual entry. + */ + private fun readSims(): WritableArray { + val sims = Arguments.createArray() + + if (!hasPermission(Manifest.permission.READ_PHONE_STATE)) { + return sims + } + + val subscriptions = subscriptionManager() + + val active = + try { + subscriptions?.activeSubscriptionInfoList + } catch (error: SecurityException) { + null + } ?: return sims + + active.forEach { info -> + sims.pushMap( + Arguments.createMap().apply { + putInt("subscriptionId", info.subscriptionId) + // Slots are 0-based here; the UI presents them as SIM 1 / SIM 2. + putInt("slotIndex", info.simSlotIndex) + + val carrier = info.carrierName?.toString()?.takeIf { it.isNotBlank() } + if (carrier == null) putNull("carrierName") else putString("carrierName", carrier) + + // The label the customer gave the SIM in Android settings, when they + // set one — more recognisable than the carrier for someone with two + // lines on the same network. + val label = info.displayName?.toString()?.takeIf { it.isNotBlank() } + if (label == null) putNull("displayName") else putString("displayName", label) + + val number = numberFor(subscriptions, info) + if (number == null) putNull("phoneNumber") else putString("phoneNumber", number) + }, + ) + } + + return sims + } + + private fun readNumber(telephony: TelephonyManager): String? { + // API 33 moved the supported path to SubscriptionManager; getLine1Number + // is deprecated there and returns blank more often than not. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermission(Manifest.permission.READ_PHONE_NUMBERS) + ) { + val subscriptions = + reactApplicationContext.getSystemService(SubscriptionManager::class.java) + + val active = + try { + subscriptions?.activeSubscriptionInfoList + } catch (error: SecurityException) { + null + } + + // A dual-SIM handset can have a number on either slot, so take the first + // one the carrier actually provisioned rather than assuming slot 0. + active?.forEach { info -> + val candidate = + try { + subscriptions?.getPhoneNumber(info.subscriptionId) + } catch (error: SecurityException) { + null + } + + normalise(candidate)?.let { return it } + } + } + + @Suppress("DEPRECATION") + return try { + normalise(telephony.line1Number) + } catch (error: SecurityException) { + null + } + } + + @ReactMethod + fun getSimInfo(promise: Promise) { + try { + val telephony = + reactApplicationContext.getSystemService(TelephonyManager::class.java) + + if (telephony == null) { + promise.resolve( + Arguments.createMap().apply { + putBoolean("hasSim", false) + putBoolean("hasPermission", false) + putBoolean("canReadNumbers", false) + putNull("phoneNumber") + putNull("carrierName") + putArray("sims", Arguments.createArray()) + }, + ) + return + } + + /* + * The two grants do different jobs and are reported separately. + * + * READ_PHONE_STATE enumerates the slots. READ_PHONE_NUMBERS is what + * `SubscriptionManager.getPhoneNumber()` demands from Android 13 onward, + * and below 13 either grant will satisfy the legacy field. + * + * These used to be collapsed into one OR'd `granted` flag, which said + * "permitted" whenever READ_PHONE_STATE alone had been given. On Android + * 13 that reads every slot and no numbers — and the caller, seeing + * permission granted and no numbers, blamed the carrier and told the + * customer to visit a branch over a permission the app had never asked + * for. + */ + val hasState = hasPermission(Manifest.permission.READ_PHONE_STATE) + val hasNumbers = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + hasPermission(Manifest.permission.READ_PHONE_NUMBERS) + val canReadNumbers = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + hasNumbers + } else { + hasState || hasNumbers + } + + val hasSim = telephony.simState == TelephonyManager.SIM_STATE_READY + + val result = + Arguments.createMap().apply { + putBoolean("hasSim", hasSim) + putBoolean("hasPermission", hasState) + putBoolean("canReadNumbers", canReadNumbers) + + val number = if (canReadNumbers && hasSim) readNumber(telephony) else null + + if (number == null) putNull("phoneNumber") else putString("phoneNumber", number) + + val carrier = telephony.simOperatorName?.takeIf { it.isNotBlank() } + + if (carrier == null) putNull("carrierName") else putString("carrierName", carrier) + + // Enumerated separately from the single `phoneNumber` above, which + // stays for the case a caller only wants a best-guess prefill. + putArray("sims", if (hasState) readSims() else Arguments.createArray()) + } + + promise.resolve(result) + } catch (error: Exception) { + // Never reject: a handset that will not talk about its SIM is a case the + // registration screen handles, not an error worth crashing a flow over. + promise.resolve( + Arguments.createMap().apply { + putBoolean("hasSim", false) + putBoolean("hasPermission", false) + putBoolean("canReadNumbers", false) + putNull("phoneNumber") + putNull("carrierName") + putArray("sims", Arguments.createArray()) + }, + ) + } + } + + companion object { + const val NAME = "SimCard" + } +} diff --git a/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardPackage.kt b/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardPackage.kt new file mode 100644 index 0000000..c4c403a --- /dev/null +++ b/android/app/src/main/java/com/bankcbs/mobile/sim/SimCardPackage.kt @@ -0,0 +1,19 @@ +package com.bankcbs.mobile.sim + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class SimCardPackage : ReactPackage { + + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): List = listOf(SimCardModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List>> = emptyList() +} diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..61f8c56 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,38 @@ + + + + + + mobile-banking-backend.lincpay + 10.209.40.168 + 192.168.6.165 + 192.168.1.11 + 192.168.6.128 + 192.168.1.10 + + + localhost + 127.0.0.1 + + + 10.0.2.2 + + diff --git a/android/local.properties.bak b/android/local.properties.bak new file mode 100644 index 0000000..eee7657 --- /dev/null +++ b/android/local.properties.bak @@ -0,0 +1 @@ +sdk.dir=C:\\Users\\Lincpay\\AppData\\Local\\Android\\Sdk diff --git a/assets/fonts/IBMPlexMono-SmBld.ttf b/assets/fonts/IBMPlexMono-SmBld.ttf new file mode 100644 index 0000000..a48e40a Binary files /dev/null and b/assets/fonts/IBMPlexMono-SmBld.ttf differ diff --git a/assets/fonts/IBMPlexMono.ttf b/assets/fonts/IBMPlexMono.ttf new file mode 100644 index 0000000..4254c37 Binary files /dev/null and b/assets/fonts/IBMPlexMono.ttf differ diff --git a/assets/fonts/IBMPlexSans-Bold.ttf b/assets/fonts/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..1d66b1a Binary files /dev/null and b/assets/fonts/IBMPlexSans-Bold.ttf differ diff --git a/assets/fonts/IBMPlexSans-Medm.ttf b/assets/fonts/IBMPlexSans-Medm.ttf new file mode 100644 index 0000000..a3826c4 Binary files /dev/null and b/assets/fonts/IBMPlexSans-Medm.ttf differ diff --git a/assets/fonts/IBMPlexSans-SmBld.ttf b/assets/fonts/IBMPlexSans-SmBld.ttf new file mode 100644 index 0000000..09ec8cd Binary files /dev/null and b/assets/fonts/IBMPlexSans-SmBld.ttf differ diff --git a/assets/fonts/IBMPlexSans.ttf b/assets/fonts/IBMPlexSans.ttf new file mode 100644 index 0000000..bd6817d Binary files /dev/null and b/assets/fonts/IBMPlexSans.ttf differ diff --git a/assets/fonts/Nunito-Bold.ttf b/assets/fonts/Nunito-Bold.ttf deleted file mode 100644 index a6e17dd..0000000 --- a/assets/fonts/Nunito-Bold.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/assets/fonts/Nunito-ExtraBold.ttf b/assets/fonts/Nunito-ExtraBold.ttf deleted file mode 100644 index b4c79b7..0000000 --- a/assets/fonts/Nunito-ExtraBold.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/assets/fonts/Nunito-Regular.ttf b/assets/fonts/Nunito-Regular.ttf deleted file mode 100644 index b73048c..0000000 --- a/assets/fonts/Nunito-Regular.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/assets/fonts/Nunito-SemiBold.ttf b/assets/fonts/Nunito-SemiBold.ttf deleted file mode 100644 index 734112e..0000000 --- a/assets/fonts/Nunito-SemiBold.ttf +++ /dev/null @@ -1,1459 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/download-fonts.js b/download-fonts.js index 47ba9c5..4d4c8f6 100644 --- a/download-fonts.js +++ b/download-fonts.js @@ -1,12 +1,57 @@ -const https = require('https'); +/** + * Fetches the app's font files into assets/fonts/. + * + * ── WHY THIS SCRIPT IS THE WAY IT IS ──────────────────────────────────────── + * The previous version pointed at `googlefonts/nunito/raw/main/fonts/ttf/`, + * which no longer exists — that repo now ships variable fonts only. It also + * piped whatever came back straight into the .ttf without looking at the HTTP + * status. So running it after the URLs died wrote GitHub's 404 page into all + * four font files, and every one of them was a 300KB HTML document with a + * .ttf extension. + * + * Nothing complained. React Native does not validate a font asset: Android + * silently falls back to Roboto, so the app rendered in the system font while + * appearing to have a typeface of its own. That is the failure this script now + * refuses to repeat: + * + * - a non-2xx response aborts instead of being written to disk; + * - every download is checked for the TrueType magic number (0x00010000) + * before it is kept; + * - a file that fails either check is deleted, and the script exits non-zero. + * + * ── WHY THE FILENAMES LOOK ODD ────────────────────────────────────────────── + * They are the fonts' own PostScript names, not IBM's file names. iOS resolves + * `fontFamily` against the PostScript name while Android resolves it against + * the asset filename, so the two only agree if the file is named after the + * PostScript name. IBM abbreviates: SemiBold is `SmBld`, Medium is `Medm`, and + * Regular has no suffix at all. Renaming them here is what lets + * `fontFamily: 'IBMPlexSans-SmBld'` work on both platforms. + * + * After running this, re-link and rebuild — the files have to be inside the + * binary, so a Metro reload alone will not pick them up: + * + * node download-fonts.js + * npx react-native-asset + * npx react-native run-android + */ + +/* eslint-disable no-undef -- A Node build script, not app code: the project's + ESLint environment is React Native and does not declare Node globals. */ + const fs = require('fs'); +const https = require('https'); const path = require('path'); +const PLEX = 'https://raw.githubusercontent.com/IBM/plex/master/packages'; + +/** `as` is the PostScript name — see the note above on why that is the filename. */ const fonts = [ - { name: 'Nunito-Regular.ttf', url: 'https://github.com/googlefonts/nunito/raw/main/fonts/ttf/Nunito-Regular.ttf' }, - { name: 'Nunito-SemiBold.ttf', url: 'https://github.com/googlefonts/nunito/raw/main/fonts/ttf/Nunito-SemiBold.ttf' }, - { name: 'Nunito-Bold.ttf', url: 'https://github.com/googlefonts/nunito/raw/main/fonts/ttf/Nunito-Bold.ttf' }, - { name: 'Nunito-ExtraBold.ttf', url: 'https://github.com/googlefonts/nunito/raw/main/fonts/ttf/Nunito-ExtraBold.ttf' }, + { as: 'IBMPlexSans.ttf', url: `${PLEX}/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf` }, + { as: 'IBMPlexSans-Medm.ttf', url: `${PLEX}/plex-sans/fonts/complete/ttf/IBMPlexSans-Medium.ttf` }, + { as: 'IBMPlexSans-SmBld.ttf', url: `${PLEX}/plex-sans/fonts/complete/ttf/IBMPlexSans-SemiBold.ttf` }, + { as: 'IBMPlexSans-Bold.ttf', url: `${PLEX}/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf` }, + { as: 'IBMPlexMono.ttf', url: `${PLEX}/plex-mono/fonts/complete/ttf/IBMPlexMono-Regular.ttf` }, + { as: 'IBMPlexMono-SmBld.ttf', url: `${PLEX}/plex-mono/fonts/complete/ttf/IBMPlexMono-SemiBold.ttf` }, ]; const dir = path.join(__dirname, 'assets', 'fonts'); @@ -15,30 +60,78 @@ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } -function download(url, dest) { - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(dest); - https.get(url, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - download(response.headers.location, dest).then(resolve).catch(reject); - return; - } - response.pipe(file); - file.on('finish', () => { - file.close(resolve); - }); - }).on('error', (err) => { - fs.unlink(dest, () => reject(err)); - }); - }); -} +/** Buffers the body so nothing reaches disk until the status has been checked. */ +const fetch = (url, redirects = 0) => + new Promise((resolve, reject) => { + if (redirects > 5) { + reject(new Error('too many redirects')); + return; + } -Promise.all(fonts.map(font => { - const dest = path.join(dir, font.name); - console.log(`Downloading ${font.name}...`); - return download(font.url, dest); -})).then(() => { - console.log('All fonts downloaded successfully!'); -}).catch(err => { - console.error('Error downloading fonts:', err); -}); + https + .get(url, (response) => { + const { statusCode, headers } = response; + + if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) { + response.resume(); + fetch(headers.location, redirects + 1).then(resolve, reject); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject(new Error(`HTTP ${statusCode}`)); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); + }) + .on('error', reject); + }); + +/** + * A TrueType file opens with sfntVersion 0x00010000. `OTTO` would be an + * OpenType/CFF font, which React Native on Android will not load, and anything + * else — an HTML error page, most likely — is not a font at all. + */ +const isTrueType = (buffer) => buffer.length > 4 && buffer.readUInt32BE(0) === 0x00010000; + +const run = async () => { + let failed = 0; + + for (const font of fonts) { + const dest = path.join(dir, font.as); + + try { + const body = await fetch(font.url); + + if (!isTrueType(body)) { + const magic = body.length > 4 ? body.readUInt32BE(0).toString(16) : 'empty'; + throw new Error(`not a TrueType file (magic 0x${magic}, ${body.length} bytes)`); + } + + fs.writeFileSync(dest, body); + console.log(` ok ${font.as} (${body.length} bytes)`); + } catch (error) { + failed += 1; + // Leave nothing half-written behind: a stale or truncated file here is + // exactly what caused the silent fallback this script exists to prevent. + fs.rmSync(dest, { force: true }); + console.error(` FAILED ${font.as} — ${error.message}`); + } + } + + if (failed > 0) { + console.error(`\n${failed} font(s) could not be fetched. Do not build with fonts missing.`); + process.exitCode = 1; + return; + } + + console.log('\nAll fonts downloaded and verified.'); + console.log('Next: npx react-native-asset && npx react-native run-android'); +}; + +void run(); diff --git a/index.js b/index.js index debaf9b..48740ab 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,11 @@ * @format */ +// Must be the first import: it installs `crypto.getRandomValues`, which `uuid` +// binds at module load. Anything importing `uuid` before this — the device id +// and the MPIN nonce both do — would otherwise throw at startup. +import 'react-native-get-random-values'; + import { AppRegistry, Text, TextInput } from 'react-native'; import notifee from '@notifee/react-native'; import App from './App'; diff --git a/ios/BankCBS/Info.plist b/ios/BankCBS/Info.plist index 69500f3..d5a0c5c 100644 --- a/ios/BankCBS/Info.plist +++ b/ios/BankCBS/Info.plist @@ -32,6 +32,22 @@ NSAllowsLocalNetworking + + NSExceptionDomains + + mobile-banking-backend.lincpay + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + NSLocationWhenInUseUsageDescription @@ -51,10 +67,12 @@ UIAppFonts - Nunito-Bold.ttf - Nunito-ExtraBold.ttf - Nunito-Regular.ttf - Nunito-SemiBold.ttf + IBMPlexSans.ttf + IBMPlexSans-Medm.ttf + IBMPlexSans-SmBld.ttf + IBMPlexSans-Bold.ttf + IBMPlexMono.ttf + IBMPlexMono-SmBld.ttf diff --git a/metro.config.js b/metro.config.js index 2a0a21c..298c976 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,11 +1,29 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const exclusionList = require('metro-config/private/defaults/exclusionList').default; /** * Metro configuration * https://reactnative.dev/docs/metro * + * Native build output (android/app/build alone is >2GB after a debug build) is + * excluded from the file crawl. Without watchman installed Metro walks these + * directories on startup and never becomes responsive. + * * @type {import('@react-native/metro-config').MetroConfig} */ -const config = {}; +const config = { + resolver: { + blockList: exclusionList([ + /\/android\/build\/.*/, + /\/android\/app\/build\/.*/, + /\/android\/\.gradle\/.*/, + /\/android\/\.kotlin\/.*/, + /\/node_modules\/.*\/android\/build\/.*/, + /\/\.cxx\/.*/, + /\/ios\/build\/.*/, + /\/ios\/Pods\/.*/, + ]), + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/package-lock.json b/package-lock.json index 7f41b65..eeab43a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,14 @@ "@reduxjs/toolkit": "^2.11.2", "@types/geojson": "^7946.0.16", "axios": "^1.14.0", + "node-forge": "^1.4.0", "react": "19.2.0", "react-native": "0.83.4", "react-native-biometrics": "^3.0.1", + "react-native-blob-util": "^0.25.0", "react-native-date-picker": "^5.0.13", "react-native-gesture-handler": "~2.30.0", + "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^3.0.0", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", @@ -33,7 +36,8 @@ "react-native-screens": "~4.23.0", "react-native-svg": "^15.15.4", "react-native-worklets": "0.7.2", - "react-redux": "^9.2.0" + "react-redux": "^9.2.0", + "uuid": "^11.1.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -47,6 +51,7 @@ "@react-native/metro-config": "0.83.4", "@react-native/typescript-config": "0.83.4", "@types/jest": "^29.5.13", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "eslint": "^8.19.0", @@ -3963,6 +3968,16 @@ "undici-types": "~7.21.0" } }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4869,6 +4884,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6849,6 +6870,12 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-base64-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", + "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10054,6 +10081,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10946,6 +10982,23 @@ "react-native": ">=0.60.0" } }, + "node_modules/react-native-blob-util": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.25.0.tgz", + "integrity": "sha512-+T6IAB67nh4hPfs3BtM2WObofxFeGFJXWQvzkaQZ2kGx1aa/6HZ0IC3BsSr4762fIdKhuDA8F+wbGNzfsHCBMw==", + "license": "MIT", + "dependencies": { + "base-64": "1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ronradtke" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-date-picker": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/react-native-date-picker/-/react-native-date-picker-5.0.13.tgz", @@ -10971,6 +11024,18 @@ "react-native": "*" } }, + "node_modules/react-native-get-random-values": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", + "license": "MIT", + "dependencies": { + "fast-base64-decode": "^1.0.0" + }, + "peerDependencies": { + "react-native": ">=0.56" + } + }, "node_modules/react-native-haptic-feedback": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-3.0.0.tgz", @@ -12709,7 +12774,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12889,6 +12954,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 74ee052..e095df0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "start": "react-native start", "lint": "eslint .", "test": "jest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "features:audit": "node scripts/audit-features.js", + "features:audit:strict": "node scripts/audit-features.js --strict", + "clean:android": "node -e \"const fs=require('fs');['android/app/.cxx','android/app/build','android/build','android/.gradle'].forEach(p=>{fs.rmSync(p,{recursive:true,force:true});console.log('removed '+p)})\"" }, "dependencies": { "@gorhom/bottom-sheet": "^5.2.14", @@ -23,11 +26,14 @@ "@reduxjs/toolkit": "^2.11.2", "@types/geojson": "^7946.0.16", "axios": "^1.14.0", + "node-forge": "^1.4.0", "react": "19.2.0", "react-native": "0.83.4", "react-native-biometrics": "^3.0.1", + "react-native-blob-util": "^0.25.0", "react-native-date-picker": "^5.0.13", "react-native-gesture-handler": "~2.30.0", + "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^3.0.0", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", @@ -36,7 +42,8 @@ "react-native-screens": "~4.23.0", "react-native-svg": "^15.15.4", "react-native-worklets": "0.7.2", - "react-redux": "^9.2.0" + "react-redux": "^9.2.0", + "uuid": "^11.1.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -50,6 +57,7 @@ "@react-native/metro-config": "0.83.4", "@react-native/typescript-config": "0.83.4", "@types/jest": "^29.5.13", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "eslint": "^8.19.0", diff --git a/scripts/audit-features.js b/scripts/audit-features.js new file mode 100644 index 0000000..ce428f0 --- /dev/null +++ b/scripts/audit-features.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Reports feature keys that nothing in the app actually gates. + * + * Why this exists: a key can be declared, configured, published by the server + * and shown in the toggle screens while no UI consults it. Switching it off then + * appears to work and changes nothing — the worst possible failure for a kill + * switch, because it is silent and is only discovered when someone needs it. + * + * A key counts as gated when it is referenced anywhere outside its own + * declaration (`featureKeys.ts`) and the static config (`featureConfig.ts`). + * Parent keys need no direct reference: the service resolves ancestors, so a key + * with a gated descendant is effective through the hierarchy and is reported + * separately rather than as a fault. + * + * node scripts/audit-features.js # report + * node scripts/audit-features.js --strict # exit 1 if any key is unwired + * + * `--strict` is what CI should run once the list below is empty. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'src'); +const KEYS_FILE = path.join(SRC, 'constants', 'featureKeys.ts'); +const EXCLUDED = new Set(['featureKeys.ts', 'featureConfig.ts']); + +/** + * Keys knowingly declared ahead of the UI that will consult them, with the + * reason. Listed here so the audit stays useful — an unreviewed key shows up as + * a finding, while these show as accepted. + * + * Remove an entry when you wire its UI up. Do not add one to quieten the audit + * unless the feature genuinely has no screen yet. + */ +const ACCEPTED_UNWIRED = { + 'auth.login.forgotPassword': + 'No forgot-MPIN flow exists by policy — a reset needs a branch visit.', + 'auth.login.createAccount': 'Account opening is not in the customer app yet.', + 'home.services': 'Home services grid is commented out in HomeScreen.', + 'home.services.transfer': 'Part of the commented-out home services grid.', + 'home.services.selfTransfer': 'Part of the commented-out home services grid.', + 'home.services.billPay': 'Part of the commented-out home services grid.', + 'home.services.collectRequest': 'Part of the commented-out home services grid.', + 'home.services.managePayee': 'Part of the commented-out home services grid.', + 'home.services.support': 'Part of the commented-out home services grid.', + 'home.services.insurance': 'Part of the commented-out home services grid.', + 'home.services.deposits': 'Part of the commented-out home services grid.', + 'home.services.goldLoan': 'Part of the commented-out home services grid.', + 'home.insights': 'InsightsCard component exists but is not rendered anywhere.', + 'home.promoBanner': 'PromoBanner component exists but is not rendered anywhere.', + 'payments.upi': 'No UPI screens have been built.', + 'payments.upi.scan': 'No UPI screens have been built.', +}; + +const walk = (dir, files = []) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walk(full, files); + } else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.has(entry.name)) { + files.push(full); + } + } + + return files; +}; + +const source = fs.readFileSync(KEYS_FILE, 'utf8'); +const declarations = [...source.matchAll(/^\s+([A-Z_0-9]+) = '([a-zA-Z0-9.]+)',/gm)].map((m) => ({ + member: m[1], + path: m[2], +})); + +if (declarations.length === 0) { + console.error('No feature keys found — has featureKeys.ts moved or changed shape?'); + process.exit(2); +} + +const corpus = walk(SRC) + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); + +const referenced = new Set( + [...corpus.matchAll(/FeatureKey\.([A-Z_0-9]+)/g)].map((m) => m[1]), +); + +const gatedPaths = new Set( + declarations.filter((d) => referenced.has(d.member)).map((d) => d.path), +); + +const viaHierarchy = []; +const unwired = []; +const accepted = []; + +for (const declaration of declarations) { + if (gatedPaths.has(declaration.path)) { + continue; + } + + if ([...gatedPaths].some((gated) => gated.startsWith(`${declaration.path}.`))) { + viaHierarchy.push(declaration); + } else if (ACCEPTED_UNWIRED[declaration.path]) { + accepted.push(declaration); + } else { + unwired.push(declaration); + } +} + +const pad = (value, width) => String(value).padEnd(width); + +console.log(`\nFeature gating audit — ${declarations.length} declared keys\n`); +console.log(` gated directly ${gatedPaths.size}`); +console.log(` via hierarchy ${viaHierarchy.length} (parent of a gated key)`); +console.log(` accepted unwired ${accepted.length} (no UI yet, reason recorded)`); +console.log(` UNWIRED ${unwired.length}`); + +if (accepted.length > 0) { + console.log('\nAccepted — declared ahead of their UI:'); + for (const { path: keyPath } of accepted.sort((a, b) => a.path.localeCompare(b.path))) { + console.log(` ${pad(keyPath, 34)} ${ACCEPTED_UNWIRED[keyPath]}`); + } +} + +if (unwired.length > 0) { + console.log('\nUNWIRED — declared, configurable, but nothing reads them.'); + console.log('Switching one of these off will appear to work and change nothing.\n'); + for (const { path: keyPath, member } of unwired.sort((a, b) => a.path.localeCompare(b.path))) { + console.log(` ${pad(keyPath, 40)} FeatureKey.${member}`); + } + console.log('\nEither gate the UI it should control, or record it in ACCEPTED_UNWIRED'); + console.log('in this script with the reason.\n'); +} else { + console.log('\nEvery declared key is either gated or has a recorded reason.\n'); +} + +if (process.argv.includes('--strict') && unwired.length > 0) { + process.exit(1); +} diff --git a/scripts/checkContrast.js b/scripts/checkContrast.js new file mode 100644 index 0000000..cbb5c18 --- /dev/null +++ b/scripts/checkContrast.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Measures every text-on-surface pairing in both colour ramps and fails if one + * drops under AA. + * + * node scripts/checkContrast.js + * + * Run it after changing anything in src/theme/palette.ts. The ratios written + * in the comments there came from this script; if you change a value and do + * not re-run it, those comments become fiction. + * + * It reads palette.ts by parsing it rather than importing it, so it needs no + * TypeScript toolchain and can run in a git hook or in CI as plain node. + * + * Exit code 0 = every pairing passes. 1 = at least one failure, listed. + */ + +const fs = require('fs'); +const path = require('path'); + +const PALETTE_PATH = path.join(__dirname, '..', 'src', 'theme', 'palette.ts'); + +/* ── WCAG 2.1 relative luminance and contrast ratio ──────────────────────── */ + +const toRgb = (color) => { + const rgba = color.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgba) return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])]; + + let h = color.replace('#', '').trim(); + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); +}; + +/** The alpha of an rgba() string, or 1 for an opaque colour. */ +const alphaOf = (color) => { + const m = color.match(/rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i); + return m ? Number(m[1]) : 1; +}; + +const channel = (c) => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); +}; + +const luminance = (color) => { + const [r, g, b] = toRgb(color); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +/** + * Composite a translucent colour over an opaque backdrop. + * + * Necessary because the on-brand tiers are translucent white, and a contrast + * ratio is only meaningful between two opaque colours — the number that + * matters is the one the composite actually paints. + */ +const composite = (color, backdrop) => { + const a = alphaOf(color); + if (a === 1) return color; + const f = toRgb(color); + const b = toRgb(backdrop); + const mixed = f.map((c, i) => Math.round(c * a + b[i] * (1 - a))); + return `#${mixed.map((c) => c.toString(16).padStart(2, '0')).join('')}`; +}; + +const ratio = (fg, bg) => { + const a = luminance(composite(fg, bg)); + const b = luminance(bg); + const [hi, lo] = a > b ? [a, b] : [b, a]; + return (hi + 0.05) / (lo + 0.05); +}; + +/* ── parse the two ramps out of palette.ts ───────────────────────────────── */ + +const parseRamp = (source, name) => { + const start = source.indexOf(`export const ${name}: Palette = {`); + if (start === -1) throw new Error(`Could not find the "${name}" ramp in palette.ts`); + + // Walk braces so a nested value cannot end the block early. + let depth = 0; + let end = -1; + for (let i = source.indexOf('{', start); i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + + const body = source.slice(start, end); + const ramp = {}; + + // key: '#hex' | 'rgba(...)' + for (const [, key, value] of body.matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) { + ramp[key] = value; + } + // category: { blue: { tone: '..', surface: '..' }, ... } + ramp.category = {}; + for (const [, hue, tone, surface] of body.matchAll( + /(\w+):\s*\{\s*tone:\s*'([^']+)',\s*surface:\s*'([^']+)'\s*\}/g, + )) { + ramp.category[hue] = { tone, surface }; + } + + // key: ['a', 'b', 'c'] + for (const [, key, list] of body.matchAll(/^\s*(\w+):\s*\[([^\]]+)\]/gm)) { + ramp[key] = list.match(/'([^']+)'/g).map((s) => s.slice(1, -1)); + } + + return ramp; +}; + +/* ── the rules ───────────────────────────────────────────────────────────── */ + +/** + * 4.6 rather than 4.5: a 0.1 margin over AA so rounding on a device cannot + * drop a pairing under the bar it was signed off at. + */ +const AA = 4.6; +/** Agent screens are used outdoors and hold to a higher bar. */ +const AA_AGENT = 7; +/** + * A tint surface has to be this distinguishable from a plain card, or a banner + * reads as untinted. + * + * 1.06 is the floor the approved light ramp actually achieves — its + * `accentSurface` (#FEF7EA on white) sits at 1.066, the quietest tint in the + * design. The bar is set to that rather than to the ramp's 1.123 average so it + * asserts the real contract instead of failing the shipped design; it still + * catches a genuinely invisible tint, which is what this check is for. + */ +const TINT_SEPARATION = 1.06; +/** A border has to be at least this visible against the card it sits on. */ +const BORDER_VISIBILITY = 1.25; +/** Two adjacent surface fills need at least this much step between them. */ +const SURFACE_STEP = 1.06; + +const TEXT_SURFACES = ['surface', 'background', 'surfaceMuted']; +const TEXT_TONES = ['textPrimary', 'textSecondary', 'textMuted']; +const SEMANTIC = ['success', 'warning', 'danger', 'info']; +const ALL_TONES = ['brand', 'accent', ...SEMANTIC]; + +const checkRamp = (ramp, rampName) => { + const results = []; + const add = (ok, label, value, bar) => + results.push({ ok, label, value, bar, ramp: rampName }); + + const at = (fg, bg, bar, label) => add(ratio(ramp[fg], ramp[bg]) >= bar, label, ratio(ramp[fg], ramp[bg]), bar); + + // Body text and every tone, on all three text-bearing surfaces. + for (const tone of [...TEXT_TONES, ...ALL_TONES]) { + for (const surface of TEXT_SURFACES) { + at(tone, surface, AA, `${tone} on ${surface}`); + } + } + + // surfaceSunk is non-text, but these three are documented as legible on it. + for (const tone of ['textPrimary', 'textSecondary', 'brand']) { + at(tone, 'surfaceSunk', AA, `${tone} on surfaceSunk`); + } + + // Each tone on its own tint surface — a banner shows both at once. + for (const tone of ALL_TONES) { + const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; + at(tone, surface, AA, `${tone} on ${surface}`); + } + + // Body copy on every tint surface, for a banner's message text. + for (const tone of ALL_TONES) { + const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; + at('textPrimary', surface, AA, `textPrimary on ${surface}`); + at('textSecondary', surface, AA, `textSecondary on ${surface}`); + } + + // The fill rule: whatever `textOnFill` is for this ramp must work on every + // saturated fill. This is the check that catches a white label in dark mode. + // + // `accentFill` is the documented exception and is checked separately below. + // It is the vivid brass, and it takes ink in BOTH ramps — white on it is + // 2.26:1. Including it here would demand that `textOnFill` work on it, which + // is exactly the thing the palette says not to do. + for (const tone of ['brand', ...SEMANTIC]) { + at('textOnFill', tone, AA, `textOnFill on ${tone} fill`); + } + at('textOnAccentFill', 'accentFill', AA, 'textOnAccentFill on accentFill'); + + // Agent screens: the primary pairing has to clear the outdoor bar. + at('textPrimary', 'surface', AA_AGENT, 'textPrimary on surface (agent bar)'); + + // A tint surface must be distinguishable from a plain card. + for (const tone of ALL_TONES) { + const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`; + at(surface, 'surface', TINT_SEPARATION, `${surface} distinct from surface`); + } + + // Borders have to be visible against the card they edge. + for (const border of ['border', 'borderStrong', 'brandBorder', 'accentBorder', + ...SEMANTIC.map((t) => `${t}Border`)]) { + at(border, 'surface', BORDER_VISIBILITY, `${border} visible on surface`); + } + + // Adjacent surface fills need a visible step, which is what carries depth + // once the shadow stops doing anything (Android, and dark mode generally). + at('background', 'surface', SURFACE_STEP, 'background / surface step'); + at('surface', 'surfaceMuted', SURFACE_STEP, 'surface / surfaceMuted step'); + at('surfaceMuted', 'surfaceSunk', SURFACE_STEP, 'surfaceMuted / surfaceSunk step'); + + // Categorical hues: the icon tone has to be legible on a plain card AND on + // its own tint chip, and the chip has to be visible against the card. This + // is what the old `${hue}15` alpha suffix could not deliver in dark mode. + for (const [hue, pair] of Object.entries(ramp.category)) { + const onCard = ratio(pair.tone, ramp.surface); + add(onCard >= AA, `category.${hue} tone on surface`, onCard, AA); + const onTint = ratio(pair.tone, pair.surface); + add(onTint >= AA, `category.${hue} tone on its tint`, onTint, AA); + const sep = ratio(pair.surface, ramp.surface); + add(sep >= TINT_SEPARATION, `category.${hue} tint distinct from surface`, sep, TINT_SEPARATION); + } + + // The on-brand tiers, against the LIGHTEST stop of the wash — the worst case. + const wash = ramp.brandGradient[0]; + for (const tier of ['onBrandPrimary', 'onBrandSecondary', 'onBrandMuted']) { + const r = ratio(ramp[tier], wash); + add(r >= AA, `${tier} on the brand wash`, r, AA); + } + // And solid white on each translucent fill — the tiers do not stack, so a + // fill always carries the solid tone. + for (const fill of ['onBrandSurface', 'onBrandSurfaceStrong']) { + const composited = composite(ramp[fill], wash); + const r = ratio(ramp.onBrandPrimary, composited); + add(r >= AA, `onBrandPrimary on ${fill}`, r, AA); + } + + return results; +}; + +/* ── run ─────────────────────────────────────────────────────────────────── */ + +const source = fs.readFileSync(PALETTE_PATH, 'utf8'); +const ramps = { light: parseRamp(source, 'light'), dark: parseRamp(source, 'dark') }; + +// Both ramps must declare the same keys — TypeScript enforces this at compile +// time, but this script also runs standalone, and a missing key here would +// otherwise show up as a confusing NaN. +const lightKeys = Object.keys(ramps.light).sort(); +const darkKeys = Object.keys(ramps.dark).sort(); +const missing = [ + ...lightKeys.filter((k) => !darkKeys.includes(k)).map((k) => `dark is missing "${k}"`), + ...darkKeys.filter((k) => !lightKeys.includes(k)).map((k) => `light is missing "${k}"`), +]; + +const all = [...checkRamp(ramps.light, 'light'), ...checkRamp(ramps.dark, 'dark')]; +const failures = all.filter((r) => !r.ok); + +for (const name of ['light', 'dark']) { + const rows = all.filter((r) => r.ramp === name); + const bad = rows.filter((r) => !r.ok); + console.log(`\n${name.toUpperCase()} ${rows.length} pairings, ${bad.length} failing`); + for (const row of bad) { + console.log(` FAIL ${row.label.padEnd(44)} ${row.value.toFixed(2)} (needs ${row.bar})`); + } +} + +if (missing.length) { + console.log('\nRAMP MISMATCH'); + missing.forEach((m) => console.log(` ${m}`)); +} + +if (failures.length || missing.length) { + console.log(`\n${failures.length} contrast failure(s), ${missing.length} mismatch(es).`); + console.log('Adjust the value in src/theme/palette.ts — do not delete the check.'); + process.exit(1); +} + +console.log(`\nAll ${all.length} pairings pass in both ramps.`); diff --git a/src/components/AccountCard/index.tsx b/src/components/AccountCard/index.tsx new file mode 100644 index 0000000..d58390d --- /dev/null +++ b/src/components/AccountCard/index.tsx @@ -0,0 +1,240 @@ +import React, { memo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import type { CbsAccount } from '../../types/cbs'; +import { useTheme, textStyles } from '../../theme'; + +interface AccountCardProps { + account: CbsAccount; + /** + * The customer's name, from the session. Passed in rather than read here: + * this is a shared presentational primitive and none of them reach into app + * state. It is also not account data — `/accounts` returns no holder name. + */ + accountHolder?: string; + /** + * Whether the reveal control for the account number is offered. When false + * the number stays masked with no way to reveal it. + */ + canRevealNumber?: boolean; +} + +/** + * The hero card for one account. + * + * This was a balance card until the app moved to `GET /accounts`, which + * returns no balance — and no other CBS endpoint returns one either. Rather + * than print a figure the bank never sent, the card now leads with what it can + * actually vouch for: which account this is, and whether it is usable. + * + * The account number is masked by default. `/accounts` is the only endpoint + * that returns an unmasked one, so revealing it is a deliberate act. + */ +const AccountCardComponent = ({ + account, + accountHolder, + canRevealNumber = true, +}: AccountCardProps) => { + const theme = useTheme(); + const [revealed, setRevealed] = useState(false); + + const showFull = revealed && canRevealNumber; + const displayNumber = showFull + ? account.accountNumber + : `XXXX ${account.accountNumber.slice(-4)}`; + + return ( + + + + + + + + + + + CBS Bank + + + {`${account.accountType.toUpperCase()} ACCOUNT`} + + + + + {/* A non-active account has to say so here: this is the card the + customer picks an account from, and dormant is the reason a later + transfer would be refused. */} + {account.status !== 'ACTIVE' ? ( + + + {account.status} + + + ) : null} + + + + + ACCOUNT NUMBER + + + + {displayNumber} + + {canRevealNumber ? ( + setRevealed((current) => !current)} + style={[styles.revealButton, { backgroundColor: theme.colors.onBrandSurface }]} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={showFull ? 'Hide account number' : 'Show account number'} + > + + + ) : null} + + + + + + + {accountHolder ? 'ACCOUNT HOLDER' : 'IFSC'} + + + {accountHolder ?? account.ifsc} + + + + + {account.mmid ? 'MMID' : 'IFSC'} + + + {account.mmid ?? account.ifsc} + + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + padding: 24, + gap: 32, + position: 'relative', + minHeight: 260, + }, + gradientCard: { + overflow: 'hidden', + }, + brandAccent: { + position: 'absolute', + top: -50, + right: -50, + width: 200, + height: 200, + borderRadius: 100, + }, + topRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + bankInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + bankIconFrame: { + width: 40, + height: 40, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + bankName: { + ...textStyles.h3, + }, + cardType: { + ...textStyles.eyebrow, + fontSize: 10, + }, + statusBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 10, + }, + statusBadgeText: { + ...textStyles.eyebrow, + fontSize: 10, + letterSpacing: 0.5, + }, + mainContent: { + gap: 6, + }, + numberLabel: { + ...textStyles.eyebrow, + letterSpacing: 1, + }, + numberRow: { + flexDirection: 'row', + alignItems: 'center', + }, + // An account number, so it is set in mono — a customer reads these back a + // group at a time. + numberValue: { + ...textStyles.code, + flex: 1, + fontSize: 22, + letterSpacing: 1.5, + }, + revealButton: { + marginLeft: 12, + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + footerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + marginTop: 4, + }, + footerItem: { + gap: 4, + flexShrink: 1, + }, + footerItemEnd: { + alignItems: 'flex-end', + }, + footerLabel: { + ...textStyles.eyebrow, + fontSize: 9, + letterSpacing: 1, + }, + footerValue: { + ...textStyles.bodyStrong, + fontSize: 14, + }, +}); + +export const AccountCard = memo(AccountCardComponent); diff --git a/src/components/AccountSelector/index.tsx b/src/components/AccountSelector/index.tsx new file mode 100644 index 0000000..417a591 --- /dev/null +++ b/src/components/AccountSelector/index.tsx @@ -0,0 +1,135 @@ +import React, { memo } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { useTheme, fontFamily } from '../../theme'; +import type { CbsAccount } from '../../types/cbs'; + +interface AccountSelectorProps { + /** The accounts to offer, as `GET /accounts` returned them. */ + accounts: CbsAccount[]; + /** The chosen account, addressed by its number — these have no local id. */ + selectedAccountNumber?: string; + onSelect: (accountNumber: string) => void; + /** Section label above the row — "Debit from", "Credit to", "Drawn on account". */ + label?: string; + /** Greys the row out and stops selection while a request is in flight. */ + disabled?: boolean; +} + +/** + * The account chooser that heads every account-scoped form. + * + * A row of chips rather than a dropdown: this app's customers hold two or three + * accounts, so every option fits on screen and the choice costs one tap instead + * of two plus a modal. + * + * Selection is drawn with a border and a tinted fill, never with colour alone — + * a chip that differed only in hue would be invisible to a customer who cannot + * separate the two, and this is the control that decides which account the + * money leaves. + * + * Accounts are shown masked. The unmasked number `GET /accounts` returns is + * carried in the value, never painted onto a chip. + */ +const AccountSelectorComponent = ({ + accounts, + selectedAccountNumber, + onSelect, + label, + disabled = false, +}: AccountSelectorProps) => { + const theme = useTheme(); + + if (!accounts.length) { + return null; + } + + return ( + + {label ? ( + {label} + ) : null} + + + {accounts.map((account) => { + const selected = account.accountNumber === selectedAccountNumber; + + return ( + onSelect(account.accountNumber)} + style={[ + styles.chip, + { + borderRadius: theme.radius.md, + backgroundColor: selected + ? theme.colors.primarySurface + : theme.colors.surfaceMuted, + borderColor: selected ? theme.colors.primary : 'transparent', + opacity: disabled ? 0.5 : 1, + }, + ]} + > + + {account.accountType.toUpperCase()} + + + {`XXXX ${account.accountNumber.slice(-4)}`} + + {/* Only ever shown when it is not ACTIVE — a status line on every + chip is noise, but a dormant account has to say so before it + is picked as the debit side of something. */} + {account.status !== 'ACTIVE' ? ( + + {account.status} + + ) : null} + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + row: { + flexDirection: 'row', + gap: 12, + paddingRight: 4, + }, + chip: { + minWidth: 150, + gap: 4, + borderWidth: 1.5, + paddingVertical: 14, + paddingHorizontal: 16, + }, + type: { + fontSize: 14, + fontFamily: fontFamily.bold, + letterSpacing: 0.3, + }, + number: { + fontSize: 15, + }, + status: { + fontSize: 12, + fontFamily: fontFamily.bold, + letterSpacing: 0.3, + }, +}); + +export const AccountSelector = memo(AccountSelectorComponent); diff --git a/src/components/ActivityTracker/index.tsx b/src/components/ActivityTracker/index.tsx new file mode 100644 index 0000000..bcfdc04 --- /dev/null +++ b/src/components/ActivityTracker/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { sessionManager } from '../../services/security/sessionManager'; + +interface ActivityTrackerProps { + children: React.ReactNode; +} + +/** + * Records that the user is still there, app-wide. + * + * Needed because the server's idle window only resets on an authenticated call, + * and most of this app reads from fixtures — someone can browse the dashboard + * for five minutes without one, and be signed out while actively using it. + * + * `onStartShouldSetResponderCapture` sees every touch on the way down and + * returning false declines to become the responder, so it never swallows a tap, + * a scroll or a gesture. A wrapper that actually captured touches would break + * every pressable beneath it. + */ +export const ActivityTracker = ({ children }: ActivityTrackerProps) => ( + { + sessionManager.noteInteraction(); + return false; + }} + > + {children} + +); + +const styles = StyleSheet.create({ + fill: { + flex: 1, + }, +}); diff --git a/src/components/AppHeader/index.tsx b/src/components/AppHeader/index.tsx index b1004e2..0e90425 100644 --- a/src/components/AppHeader/index.tsx +++ b/src/components/AppHeader/index.tsx @@ -8,7 +8,7 @@ import { import { useNavigation } from '@react-navigation/native'; import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; interface AppHeaderProps { title: string; @@ -76,7 +76,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 20, - fontWeight: '700', + fontFamily: fontFamily.bold, textTransform: "uppercase", letterSpacing: 0.5, lineHeight: 20, diff --git a/src/components/AppearanceSetting/index.tsx b/src/components/AppearanceSetting/index.tsx new file mode 100644 index 0000000..ff9b8ec --- /dev/null +++ b/src/components/AppearanceSetting/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import { SegmentedControl, type SegmentedControlOption } from '../SegmentedControl'; +import { textStyles, useTheme, useThemeMode, type ThemeMode } from '../../theme'; + +/** + * The light / dark / system control. + * + * Three options rather than a switch, because "follow my phone" is a genuinely + * different answer from "always light" — and it is the one most people want. + * A two-state switch would force everybody to state a preference the OS has + * already been told, and would then ignore it when they changed it there. + * + * `System` is listed last despite being the default. The two explicit choices + * are what someone opening this screen is looking for; `System` is where they + * go back to, and a segmented control reads left to right. + */ + +const OPTIONS: ReadonlyArray> = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' }, +]; + +export const AppearanceSetting = () => { + const theme = useTheme(); + const { mode, resolved, setMode } = useThemeMode(); + + return ( + + + + {/* + Only shown for `system`, and it names what the phone is currently set + to. Without it the control is ambiguous: `System` is selected, and + nothing on screen says which of the two that actually means — so a + customer whose handset is in light mode cannot tell whether the setting + took effect. + */} + {mode === 'system' ? ( + + + + {`Following your phone, which is set to ${resolved}.`} + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + gap: 10, + paddingVertical: 4, + }, + hintRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + hint: { + ...textStyles.caption, + flex: 1, + }, +}); diff --git a/src/components/BalanceCard/index.tsx b/src/components/BalanceCard/index.tsx deleted file mode 100644 index 7e76191..0000000 --- a/src/components/BalanceCard/index.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import React, { memo, useState } from 'react'; -import { - Pressable, - StyleSheet, - Text, - View, -} from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; -import Ionicons from '@react-native-vector-icons/ionicons'; - -import type { AccountSummary } from '../../types/app'; -import { useTheme } from '../../theme'; -import { formatCurrency } from '../../utils/formatters'; - -interface BalanceCardProps { - account: AccountSummary; - secondaryLabel?: string; -} - -const BalanceCardComponent = ({ account, secondaryLabel = 'SAVINGS ACCOUNT' }: BalanceCardProps) => { - const theme = useTheme(); - const [isBalanceVisible, setIsBalanceVisible] = useState(true); - - const formattedBalance = formatCurrency(account.availableBalance).replace('₹', ''); - - return ( - - {/* Decorative Brand Accent */} - - - - - - - - - CBS Bank - {secondaryLabel} - - - {account.isPrimary && ( - - PRIMARY - - )} - - - - A/c {account.maskedNumber} - - AVAILABLE BALANCE - - - {isBalanceVisible ? `₹${formattedBalance}` : '₹ ••••••'} - - setIsBalanceVisible((currentValue) => !currentValue)} - style={styles.visibilityButton} - hitSlop={8} - > - - - - - - - - - ACCOUNT HOLDER - {account.accountHolder} - - - UPI STATUS - - - Active - - - - - ); -}; - -const styles = StyleSheet.create({ - card: { - padding: 24, - gap: 32, - position: 'relative', - minHeight: 260, - }, - gradientCard: { - overflow: 'hidden', - }, - brandAccent: { - position: 'absolute', - top: -50, - right: -50, - width: 200, - height: 200, - borderRadius: 100, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - }, - topRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - bankInfo: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - bankIconFrame: { - width: 40, - height: 40, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.2)', - }, - bankName: { - color: 'white', - fontSize: 18, - fontWeight: '900', - letterSpacing: -0.5, - }, - cardType: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 10, - fontWeight: '800', - letterSpacing: 1, - textTransform: 'uppercase', - }, - primaryBadge: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 10, - backgroundColor: 'white', - }, - primaryBadgeText: { - color: '#124577', - fontSize: 10, - fontWeight: '900', - letterSpacing: 0.5, - }, - mainContent: { - gap: 20, - }, - maskedNumber: { - color: 'white', - fontSize: 18, - fontWeight: '700', - letterSpacing: 1.5, - }, - balanceContainer: { - gap: 4, - }, - balanceLabel: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 11, - fontWeight: '800', - letterSpacing: 1, - }, - amountRow: { - flexDirection: 'row', - alignItems: 'center', - }, - amountValue: { - color: 'white', - fontSize: 36, - fontWeight: '900', - letterSpacing: -1, - }, - visibilityButton: { - marginLeft: 12, - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.08)', - }, - footerRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', - marginTop: 4, - }, - footerItem: { - gap: 4, - }, - footerItemEnd: { - alignItems: 'flex-end', - }, - footerLabel: { - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 9, - fontWeight: '800', - letterSpacing: 1, - }, - footerValue: { - color: 'white', - fontSize: 14, - fontWeight: '700', - }, - upiRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - upiDot: { - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: '#35D6A3', - }, -}); - - - -export const BalanceCard = memo(BalanceCardComponent); diff --git a/src/components/BottomSheet/index.tsx b/src/components/BottomSheet/index.tsx index 0cff2bb..02dbbad 100644 --- a/src/components/BottomSheet/index.tsx +++ b/src/components/BottomSheet/index.tsx @@ -13,7 +13,7 @@ import { View, } from 'react-native'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; interface BottomSheetProps { visible: boolean; @@ -107,6 +107,6 @@ const styles = StyleSheet.create({ }, title: { fontSize: 18, - fontWeight: '700', + fontFamily: fontFamily.bold, }, }); diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index c27428d..3b4b431 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { ActivityIndicator, Pressable, @@ -10,152 +10,219 @@ import { type ViewStyle, type TextStyle, } from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../../theme'; import { haptics } from '../../services/haptics/haptics'; -type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'cyber'; +/** + * The variants the design system defines. `cyber` was removed — it was a + * leftover from an earlier theme, it drew a coloured glow that Android throws + * away, and nothing in the app used it. + * + * `inverse` and `onBrand` exist for the full-bleed navy sign-in screen, where + * `primary` would be navy-on-navy and simply vanish. `inverse` is the primary + * action there (white fill, navy label, 9.80:1); `onBrand` is the secondary + * one (a low-opacity white fill, white label, 4.66:1 at the lightest point of + * the gradient). + */ +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'inverse' | 'onBrand'; + +/** + * `md` is the default. `lg` is for agent screens, where the person is standing + * up and working one-handed. `sm` is for an inline action inside a row or card. + */ +type ButtonSize = 'sm' | 'md' | 'lg'; interface ButtonProps { title: string; onPress?: (event: GestureResponderEvent) => void; variant?: ButtonVariant; + size?: ButtonSize; disabled?: boolean; loading?: boolean; fullWidth?: boolean; style?: StyleProp; textStyle?: StyleProp; leftAccessory?: React.ReactNode; + rightAccessory?: React.ReactNode; + /** Defaults to `title`. Set it when the title alone is not descriptive. */ + accessibilityLabel?: string; } const ButtonComponent = ({ title, onPress, variant = 'primary', + size = 'md', disabled = false, loading = false, fullWidth = true, style, textStyle, leftAccessory, + rightAccessory, + accessibilityLabel, }: ButtonProps) => { const theme = useTheme(); + const [pressed, setPressed] = useState(false); + const inert = disabled || loading; - const handlePress = (e: GestureResponderEvent) => { - if (!disabled && !loading) { + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (inert) { + return; + } haptics.selection(); - onPress?.(e); - } - }; - - const isSolid = variant === 'primary' || variant === 'danger' || variant === 'cyber'; - - - const containerStyle = [ - styles.button, - { - borderRadius: theme.radius.md, - borderColor: - variant === 'ghost' - ? 'transparent' - : variant === 'secondary' - ? theme.colors.cardBorder - : variant === 'cyber' - ? theme.colors.cyberCyan + '30' - : 'transparent', - backgroundColor: - variant === 'secondary' ? theme.colors.cardGlass : 'transparent', - width: fullWidth ? ('100%' as const) : undefined, - opacity: disabled || loading ? 0.6 : 1, + onPress?.(event); }, - variant === 'cyber' && { - shadowColor: theme.colors.cyberCyan, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.5, - shadowRadius: 10, - elevation: 8, - }, - style, - ]; - - const content = ( - - {loading ? ( - - ) : ( - <> - {leftAccessory} - - {title} - - - )} - + [inert, onPress], ); + const height = + size === 'lg' + ? theme.layout.controlHeightAgent + : size === 'sm' + ? theme.layout.controlHeightSmall + : theme.layout.controlHeight; + + /** + * Pressed feedback is a real colour step, not a blanket opacity fade. Fading + * a navy button to 60% drops its white label under AA, so the fill darkens + * and the label stays at full strength instead. + */ + const surface = ((): { backgroundColor: string; borderColor: string; label: string } => { + if (disabled) { + return { + backgroundColor: theme.colors.surfaceSunk, + borderColor: theme.colors.cardBorderStrong, + // textSecondary is 5.38:1 on surfaceSunk, so a disabled label stays readable. + label: theme.colors.textSecondary, + }; + } + + switch (variant) { + case 'primary': + return { + backgroundColor: pressed ? theme.colors.primaryDark : theme.colors.primary, + borderColor: pressed ? theme.colors.primaryDark : theme.colors.primary, + label: theme.colors.textOnBrand, + }; + case 'danger': + return { + backgroundColor: theme.colors.danger, + borderColor: theme.colors.danger, + label: theme.colors.textOnBrand, + }; + case 'secondary': + return { + backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface, + borderColor: theme.colors.cardBorderStrong, + label: theme.colors.primary, + }; + case 'inverse': + return { + backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface, + borderColor: 'transparent', + label: theme.colors.primary, + }; + case 'onBrand': + return { + // Pressing LIGHTENS less rather than more: 0.18 is the ceiling for a + // fill that carries white text, so the feedback has to go the other + // way to stay above AA. + backgroundColor: pressed + ? theme.colors.onBrandSurface + : theme.colors.onBrandSurfaceStrong, + borderColor: theme.colors.onBrandBorder, + label: theme.colors.onBrandPrimary, + }; + case 'ghost': + default: + return { + backgroundColor: pressed ? theme.colors.primarySurface : 'transparent', + borderColor: 'transparent', + label: theme.colors.primary, + }; + } + })(); + return ( - - {isSolid ? ( - setPressed(true)} + onPressOut={() => setPressed(false)} + style={[ + styles.button, + { + minHeight: height, + borderRadius: theme.radius.md, + backgroundColor: surface.backgroundColor, + borderColor: surface.borderColor, + width: fullWidth ? '100%' : undefined, + paddingHorizontal: size === 'sm' ? theme.spacing.sm : theme.spacing.lg, + }, + // A secondary button is the one variant that reads as a raised object; + // ghost and the solid fills carry their own weight through colour. + variant === 'secondary' && !disabled && theme.elevation.level1, + style, + ]} + > + {/* The label stays mounted while loading so the button cannot change + width mid-request and shift the layout under the user's thumb. */} + {loading ? ( + + + + ) : null} + + + {leftAccessory} + - {content} - - ) : ( - content - )} + {title} + + {rightAccessory} + ); }; - const styles = StyleSheet.create({ button: { borderWidth: 1, - overflow: 'hidden', - }, - gradient: { - width: '100%', - }, - content: { - minHeight: 54, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 10, - paddingHorizontal: 18, + overflow: 'hidden', }, - label: { - fontSize: 15, - fontWeight: '700', + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + contentHidden: { + opacity: 0, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + labelSmall: { + fontSize: 13, + lineHeight: 18, }, }); diff --git a/src/components/Card/index.tsx b/src/components/Card/index.tsx index f6feb41..c35a8a8 100644 --- a/src/components/Card/index.tsx +++ b/src/components/Card/index.tsx @@ -1,30 +1,68 @@ import React, { memo, type PropsWithChildren } from 'react'; -import { - StyleSheet, - View, - type StyleProp, - type ViewStyle, -} from 'react-native'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; import { useTheme } from '../../theme'; +/** + * `default` is the resting card and the right answer almost always. + * + * `flat` has a border and no lift — use it for a card that sits inside another + * card, or for a grouped list where a shadow per row would be noise. + * + * `raised` is for the one object on a screen that has to sit above everything + * else, typically the hero balance card. Two raised cards on one screen means + * neither of them is. + * + * `muted` is a well rather than an object — a summary block, a read-only recap. + * + * `glass` is kept only because 13 call sites still pass it. There is no blur + * library in this app, so it never actually rendered as glass; it resolves to + * `default`. + */ +type CardVariant = 'default' | 'flat' | 'raised' | 'muted' | 'glass'; + interface CardProps extends PropsWithChildren { style?: StyleProp; - variant?: 'default' | 'glass'; + variant?: CardVariant; + /** Drops the built-in padding, for a card that holds an edge-to-edge list. */ + noPadding?: boolean; } -const CardComponent = ({ children, style, variant = 'default' }: CardProps) => { +const CardComponent = ({ + children, + style, + variant = 'default', + noPadding = false, +}: CardProps) => { const theme = useTheme(); + const { backgroundColor, elevation } = ((): { + backgroundColor: string; + elevation: ViewStyle; + } => { + switch (variant) { + case 'flat': + return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level0 }; + case 'raised': + return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level2 }; + case 'muted': + return { backgroundColor: theme.colors.surfaceMuted, elevation: theme.elevation.level0 }; + case 'glass': + case 'default': + default: + return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level1 }; + } + })(); + return ( { ); }; - const styles = StyleSheet.create({ card: { borderWidth: 1, - padding: 18, + // Clipping is what lets a card hold an edge-to-edge list or image without + // the child squaring off the card's own corners. + overflow: 'hidden', }, }); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 48c0371..2d1d719 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -12,7 +12,7 @@ import { import Ionicons from '@react-native-vector-icons/ionicons'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; const SCREEN_WIDTH = Dimensions.get('window').width; const DAY_SIZE = Math.floor((SCREEN_WIDTH - 80) / 7); @@ -273,8 +273,8 @@ export const DatePicker = ({ style={[ styles.dayText, { color: theme.colors.textPrimary }, - isSelected && { color: theme.colors.white, fontWeight: '800' }, - isToday && !isSelected && { color: theme.colors.primary, fontWeight: '700' }, + isSelected && { color: theme.colors.white, fontFamily: fontFamily.bold }, + isToday && !isSelected && { color: theme.colors.primary, fontFamily: fontFamily.bold }, disabled && { color: theme.colors.textMuted, opacity: 0.4 }, ]} > @@ -287,7 +287,7 @@ export const DatePicker = ({ {/* Footer */} - + setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}> Cancel @@ -299,7 +299,7 @@ export const DatePicker = ({ }} style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]} > - Today + Today @@ -382,7 +382,7 @@ export const DatePicker = ({ styles.yearRowText, { color: isActive ? theme.colors.primary : theme.colors.textPrimary, - fontWeight: isActive ? '800' : '500', + fontFamily: isActive ? fontFamily.bold : fontFamily.medium, }, ]} > @@ -462,7 +462,7 @@ const styles = StyleSheet.create({ }, label: { fontSize: 11, - fontWeight: '700', + fontFamily: fontFamily.bold, letterSpacing: 0.5, marginLeft: 4, }, @@ -477,11 +477,11 @@ const styles = StyleSheet.create({ inputText: { flex: 1, fontSize: 15, - fontWeight: '500', + fontFamily: fontFamily.medium, }, errorText: { fontSize: 12, - fontWeight: '500', + fontFamily: fontFamily.medium, marginTop: 2, marginLeft: 4, }, @@ -528,11 +528,11 @@ const styles = StyleSheet.create({ }, monthText: { fontSize: 18, - fontWeight: '800', + fontFamily: fontFamily.bold, }, yearText: { fontSize: 13, - fontWeight: '600', + fontFamily: fontFamily.semibold, marginTop: 2, }, @@ -544,7 +544,7 @@ const styles = StyleSheet.create({ }, weekdayText: { fontSize: 12, - fontWeight: '700', + fontFamily: fontFamily.bold, letterSpacing: 0.5, }, daysGrid: { @@ -558,7 +558,7 @@ const styles = StyleSheet.create({ }, dayText: { fontSize: 14, - fontWeight: '500', + fontFamily: fontFamily.medium, }, // ── Footer ─────────────────────────────────── @@ -569,7 +569,6 @@ const styles = StyleSheet.create({ marginTop: 16, paddingTop: 12, borderTopWidth: 1, - borderTopColor: 'rgba(0,0,0,0.05)', }, footerBtn: { paddingHorizontal: 20, @@ -583,7 +582,7 @@ const styles = StyleSheet.create({ }, footerBtnText: { fontSize: 14, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, // ── Month / Year picker shared ─────────────── @@ -595,7 +594,7 @@ const styles = StyleSheet.create({ }, pickerTitle: { fontSize: 17, - fontWeight: '800', + fontFamily: fontFamily.bold, }, // ── Month picker grid ──────────────────────── @@ -615,7 +614,7 @@ const styles = StyleSheet.create({ }, monthCellText: { fontSize: 15, - fontWeight: '700', + fontFamily: fontFamily.bold, }, // ── Year picker list ───────────────────────── diff --git a/src/components/EmptyState/index.tsx b/src/components/EmptyState/index.tsx index a81697d..6c7ef02 100644 --- a/src/components/EmptyState/index.tsx +++ b/src/components/EmptyState/index.tsx @@ -5,7 +5,7 @@ import { View, } from 'react-native'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; import { Button } from '../Button'; interface EmptyStateProps { @@ -57,7 +57,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 18, - fontWeight: '700', + fontFamily: fontFamily.bold, }, description: { fontSize: 14, diff --git a/src/components/InfoBanner/index.tsx b/src/components/InfoBanner/index.tsx new file mode 100644 index 0000000..a7e942e --- /dev/null +++ b/src/components/InfoBanner/index.tsx @@ -0,0 +1,164 @@ +import React, { memo } from 'react'; +import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import { useTheme } from '../../theme'; + +type InfoBannerTone = 'info' | 'warning' | 'danger' | 'success' | 'brand'; + +type IoniconName = React.ComponentProps['name']; + +interface InfoBannerProps { + /** Optional — a one-line banner reads better without one. */ + title?: string; + message: string; + tone?: InfoBannerTone; + /** Overrides the tone's default icon. */ + icon?: IoniconName; + /** Renders a text action beneath the message. */ + actionLabel?: string; + onActionPress?: () => void; + style?: StyleProp; +} + +/** + * An inline notice — the thing to reach for instead of a toast when the message + * has to stay on screen while the customer fixes something. + * + * The tone is carried by an icon as well as a colour, because a red border + * alone tells a colour-blind customer nothing, and tells an agent holding the + * phone in direct sunlight even less. + */ +const DEFAULT_ICON: Record = { + info: 'information-circle', + warning: 'warning', + danger: 'alert-circle', + success: 'checkmark-circle', + brand: 'shield-checkmark', +}; + +const InfoBannerComponent = ({ + title, + message, + tone = 'info', + icon, + actionLabel, + onActionPress, + style, +}: InfoBannerProps) => { + const theme = useTheme(); + + const palette = ((): { fg: string; bg: string; border: string } => { + switch (tone) { + case 'warning': + return { + fg: theme.colors.warning, + bg: theme.colors.warningSurface, + border: theme.colors.warningBorder, + }; + case 'danger': + return { + fg: theme.colors.danger, + bg: theme.colors.dangerSurface, + border: theme.colors.dangerBorder, + }; + case 'success': + return { + fg: theme.colors.success, + bg: theme.colors.successSurface, + border: theme.colors.successBorder, + }; + case 'brand': + return { + fg: theme.colors.primary, + bg: theme.colors.primarySurface, + border: theme.colors.primaryBorder, + }; + case 'info': + default: + return { + fg: theme.colors.info, + bg: theme.colors.infoSurface, + border: theme.colors.infoBorder, + }; + } + })(); + + return ( + + + + + {title ? ( + {title} + ) : null} + + {/* The message stays in secondary ink rather than the tone colour. + Tinting a whole paragraph is what makes these banners shout — the + icon and the tinted surface already carry the tone. */} + {message} + + {actionLabel && onActionPress ? ( + + {actionLabel} + + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + banner: { + flexDirection: 'row', + borderWidth: 1, + }, + icon: { + // Optically aligns the icon with the first line of text rather than with + // the centre of a paragraph that may run to four lines. + marginTop: 1, + }, + body: { + flex: 1, + gap: 2, + }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + marginTop: 6, + minHeight: 24, + }, +}); + +export const InfoBanner = memo(InfoBannerComponent); diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index ecdfeb1..94a4748 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useState } from 'react'; import { StyleSheet, Text, @@ -6,75 +6,158 @@ import { View, type TextInputProps, } from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolateColor, - withRepeat, - Easing -} from 'react-native-reanimated'; +import Ionicons from '@react-native-vector-icons/ionicons'; import { useTheme } from '../../theme'; interface InputProps extends TextInputProps { label?: string; error?: string; + /** Helper text under the field. Hidden while `error` is showing. */ + hint?: string; + /** Adds a marker to the label. Does not itself validate anything. */ + required?: boolean; + /** + * A short fixed prefix inside the field, separated by a vertical rule — the + * "+91" in front of a mobile number is the case this exists for. + */ + prefix?: string; leftAccessory?: React.ReactNode; rightAccessory?: React.ReactNode; + // Pulled out of the spread so it can be merged *after* the base styles below. + // Spread alone left it silently discarded, because the explicit `style` prop + // on the TextInput overwrote it — which is why SupportScreen's description + // box had been rendering as a single line despite asking for four. + style?: TextInputProps['style']; } export const Input = ({ label, error, + hint, + required = false, + prefix, leftAccessory, rightAccessory, + style, + multiline, + editable = true, + onFocus, + onBlur, ...props }: InputProps) => { const theme = useTheme(); - const focused = useSharedValue(0); + const [focused, setFocused] = useState(false); - const handleFocus = () => { - focused.value = withTiming(1, { duration: 250 }); - }; + // The parameter type is inferred from React Native's own prop type rather + // than named outright, because the focus/blur event type has been renamed + // across RN versions and hard-coding it breaks on upgrade. + const handleFocus: NonNullable = useCallback( + (event) => { + setFocused(true); + onFocus?.(event); + }, + [onFocus], + ); - const handleBlur = () => { - focused.value = withTiming(0, { duration: 250 }); - }; + const handleBlur: NonNullable = useCallback( + (event) => { + setFocused(false); + onBlur?.(event); + }, + [onBlur], + ); - const containerAnimatedStyle = useAnimatedStyle(() => ({ - borderColor: interpolateColor( - focused.value, - [0, 1], - [theme.colors.cardBorder, theme.colors.primary] - ), - backgroundColor: interpolateColor( - focused.value, - [0, 1], - ['rgba(0, 0, 0, 0.02)', '#FFFFFF'] - ), - transform: [{ scale: withTiming(focused.value === 1 ? 1.01 : 1, { duration: 200 }) }], - })); + /** + * Border width is held constant across every state on purpose. Thickening it + * on focus reflows the row and nudges the text under the caret, and the + * previous version also scaled the whole field to 1.01 — a money field that + * jumps as you tap it does not read as a bank. The state change is carried + * by colour and by the surface behind the field instead. + */ + const borderColor = error + ? theme.colors.danger + : focused + ? theme.colors.primary + : theme.colors.cardBorder; + + const backgroundColor = !editable + ? theme.colors.surfaceMuted + : error + ? theme.colors.dangerSurface + : theme.colors.surface; return ( - {label && ( - - {label.toUpperCase()} + {label ? ( + + {label} + {required ? * : null} - )} - - {leftAccessory && {leftAccessory}} + ) : null} + + + {leftAccessory ? {leftAccessory} : null} + + {prefix ? ( + + + {prefix} + + + + ) : null} + - {rightAccessory && {rightAccessory}} - - {error && {error}} + + {rightAccessory ? {rightAccessory} : null} + + + {/* An error is not signalled by colour alone — the icon carries it too, + which is what a colour-blind or sunlit user actually reads. */} + {error ? ( + + + + {error} + + + ) : hint ? ( + + + {hint} + + + ) : null} ); }; @@ -83,35 +166,49 @@ const styles = StyleSheet.create({ container: { gap: 6, }, - label: { - fontSize: 11, - fontWeight: '700', - letterSpacing: 0.5, - marginLeft: 4, - }, - inputWrapper: { + field: { flexDirection: 'row', alignItems: 'center', - height: 52, - borderRadius: 12, borderWidth: 1, - paddingHorizontal: 16, - overflow: 'hidden', + }, + fieldMultiline: { + alignItems: 'flex-start', + paddingVertical: 12, }, input: { flex: 1, - fontSize: 15, - fontWeight: '500', - height: '100%', + // Android adds its own vertical padding to a TextInput; zeroing it is what + // keeps the text optically centred inside the 52pt row. + paddingVertical: 0, }, - accessory: { + inputMultiline: { + minHeight: 96, + textAlignVertical: 'top', + }, + prefix: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, marginRight: 10, - opacity: 0.7, }, - errorText: { - fontSize: 12, - fontWeight: '500', - marginTop: 2, - marginLeft: 4, + prefixRule: { + width: 1, + height: 22, + }, + accessoryLeft: { + marginRight: 10, + }, + // Mirrored, not shared: a trailing accessory needs its gap on the leading + // side, and the old shared style pushed it away from the edge it sits on. + accessoryRight: { + marginLeft: 10, + }, + message: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + messageText: { + flex: 1, }, }); diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 612d3fe..4035b38 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -12,7 +12,7 @@ import { View, } from 'react-native'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; interface AppModalProps { visible: boolean; @@ -100,7 +100,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 20, - fontWeight: '700', + fontFamily: fontFamily.bold, }, description: { fontSize: 14, diff --git a/src/components/ScreenShell/index.tsx b/src/components/ScreenShell/index.tsx index c276bc3..cffede0 100644 --- a/src/components/ScreenShell/index.tsx +++ b/src/components/ScreenShell/index.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { - Keyboard, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, - TouchableWithoutFeedback, View, StatusBar, type StyleProp, @@ -62,9 +60,16 @@ export const ScreenShell = ({ }: ScreenShellProps) => { const theme = useTheme(); - // iOS needs 'padding' behavior, Android handles it natively via windowSoftInputMode - // but we use 'height' as a fallback for Android screens that need it - const kavBehavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const; + /** + * Android is handled by the manifest, not by React. + * + * `windowSoftInputMode="adjustResize"` already shrinks the window when the + * keyboard opens. Running a KeyboardAvoidingView on top of that shrinks the + * content a second time, so every keyboard event triggered two layout passes + * over the whole screen — which is what made lists stutter while scrolling. + * iOS has no equivalent, so it keeps the component. + */ + const usesKeyboardAvoidingView = keyboardAware && Platform.OS === 'ios'; // Base offset accounts for the navigation header + safe area const baseOffset = Platform.OS === 'ios' ? 90 : 0; const totalOffset = baseOffset + keyboardExtraOffset; @@ -83,35 +88,43 @@ export const ScreenShell = ({ ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" + // 'interactive' is iOS-only and degrades to 'none' on Android, which left + // the keyboard up for the whole scroll. + keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'} automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'} > {children} ); + /** + * Non-scrolling content — in practice, a screen whose child is its own list. + * + * This used to be wrapped in a `TouchableWithoutFeedback` that dismissed the + * keyboard on tap. That wrapper claims the touch responder on every touch + * start, so it spent every screen competing with the list inside it for the + * same gesture: swipes were dropped or arrived late. Keyboard dismissal + * belongs to the scrollable itself — `keyboardDismissMode` — which does not + * fight the gesture it is attached to. + */ const staticContent = ( - - - - {children} - - + + {children} ); const content = scrollable ? scrollContent : staticContent; - const innerContent = keyboardAware ? ( + const innerContent = usesKeyboardAvoidingView ? ( @@ -124,11 +137,7 @@ export const ScreenShell = ({ const container = ( {/* ✅ Status Bar Added */} - + {title ? ( diff --git a/src/components/SegmentedControl/index.tsx b/src/components/SegmentedControl/index.tsx new file mode 100644 index 0000000..8066766 --- /dev/null +++ b/src/components/SegmentedControl/index.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useState } from 'react'; +import { + Pressable, + StyleSheet, + Text, + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; + +import { useTheme } from '../../theme'; +import { haptics } from '../../services/haptics/haptics'; + +export interface SegmentedControlOption { + value: T; + label: string; +} + +interface SegmentedControlProps { + options: ReadonlyArray>; + value: T; + onChange: (value: T) => void; + disabled?: boolean; + /** `lg` raises the track to the 48pt agent target. */ + size?: 'md' | 'lg'; + style?: StyleProp; + accessibilityLabel?: string; +} + +/** + * Switches between two or three views of the same thing. + * + * The thumb is a real view that slides, so it animates on the UI thread and + * never depends on a pseudo-element or a blur — neither of which exists in + * React Native. Its position comes from a measured track width rather than a + * percentage, because a percentage-positioned thumb drifts by a pixel or two + * per segment and the drift is visible at three segments. + * + * Use this for a view switch, not for navigation between unrelated screens, and + * not for more than three options — past three, the labels stop fitting on a + * 360pt phone and a set of Chips is the right control instead. + */ +export function SegmentedControl({ + options, + value, + onChange, + disabled = false, + size = 'md', + style, + accessibilityLabel, +}: SegmentedControlProps) { + const theme = useTheme(); + const [trackWidth, setTrackWidth] = useState(0); + + const activeIndex = Math.max( + 0, + options.findIndex((option) => option.value === value), + ); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setTrackWidth(event.nativeEvent.layout.width); + }, []); + + const inset = 3; + const segmentWidth = trackWidth > 0 ? (trackWidth - inset * 2) / options.length : 0; + const height = size === 'lg' ? 48 : 44; + + const thumbStyle = useAnimatedStyle( + () => ({ + transform: [{ translateX: withTiming(segmentWidth * activeIndex, { duration: 180 }) }], + }), + [segmentWidth, activeIndex], + ); + + const handlePress = useCallback( + (next: T) => { + if (disabled || next === value) { + return; + } + haptics.selection(); + onChange(next); + }, + [disabled, onChange, value], + ); + + return ( + + {/* Rendered only once the track has been measured — an unmeasured thumb + would flash at zero width on the first frame. */} + {segmentWidth > 0 ? ( + + ) : null} + + {options.map((option) => { + const active = option.value === value; + + return ( + handlePress(option.value)} + style={styles.segment} + > + + {option.label} + + + ); + })} + + ); +} +const styles = StyleSheet.create({ + track: { + flexDirection: 'row', + borderWidth: 1, + // Clips the thumb to the pill on the final segment, where it otherwise + // overhangs the rounded end by a hair. + overflow: 'hidden', + }, + thumb: { + position: 'absolute', + top: 3, + left: 3, + bottom: 3, + borderWidth: 1, + borderColor: 'transparent', + }, + segment: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + fontSize: 14, + }, +}); diff --git a/src/components/Stepper/index.tsx b/src/components/Stepper/index.tsx new file mode 100644 index 0000000..8da6143 --- /dev/null +++ b/src/components/Stepper/index.tsx @@ -0,0 +1,181 @@ +import React, { memo } from 'react'; +import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import { useTheme } from '../../theme'; + +interface StepperProps { + /** Two to four short labels. Longer than four and they stop fitting a 360pt phone. */ + steps: readonly string[]; + /** Zero-based. Steps before it are done, steps after it are still to come. */ + current: number; + /** + * Set when this sits on the navy brand wash rather than a light surface. + */ + onBrand?: boolean; + style?: StyleProp; +} + +/** + * Where the customer is in a flow, and what is still ahead of them. + * + * Registration is three screens — choose a SIM, enter the code, set an MPIN — + * and until this existed the first of those gave no hint that two more were + * coming. Someone deciding whether to hand over their number is entitled to + * know how long the process is before they start it. + * + * A completed step is marked with a tick as well as a fill, so progress is not + * carried by colour alone. The whole thing is inert: it reports position, it is + * not a way to navigate, and steps are not tappable — you cannot jump to an OTP + * screen before an OTP has been sent. + */ +const StepperComponent = ({ steps, current, onBrand = false, style }: StepperProps) => { + const theme = useTheme(); + + /** + * The connector, and the outline of a step not yet reached. + * + * On brand this is a white at 0.55 rather than the shared `onBrandBorder` + * (0.24). A border token is measured for a panel edge, which sits next to a + * fill; a hairline on the open wash has nothing beside it and 0.24 came to + * 1.75:1 — under the 3:1 that non-text contrast asks for. At 0.55 it is + * 3.34:1 against the lightest point of the gradient and, at one point tall, + * still reads as a rule rather than a bar. + * + * On a light surface it stays a border tone. The rail is reinforcement in + * either case: sequence is carried by the numbers, and completion by the fill + * and the tick, so a reader who cannot make out the line loses nothing. + */ + const railColour = onBrand ? 'rgba(255, 255, 255, 0.55)' : theme.colors.cardBorderStrong; + const doneColour = onBrand ? theme.colors.onBrandPrimary : theme.colors.primary; + + return ( + + {steps.map((label, index) => { + const done = index < current; + const active = index === current; + + /** + * A done or active marker is a solid fill, so the number and tick on it + * are the solid tone — the translucent on-brand tiers are measured + * against the bare wash and lose their contrast over a fill. + */ + const markerFill = done || active ? doneColour : 'transparent'; + const markerBorder = done || active ? doneColour : railColour; + const markerInk = done || active + ? onBrand + ? theme.colors.primary + : theme.colors.textOnBrand + : onBrand + ? theme.colors.onBrandSecondary + : theme.colors.textMuted; + + const labelColour = active + ? onBrand + ? theme.colors.onBrandPrimary + : theme.colors.textPrimary + : onBrand + ? theme.colors.onBrandSecondary + : theme.colors.textMuted; + + return ( + + {/* The rails are halves either side of the marker, each flexing to + fill its own gap. Drawing one line behind the whole row instead + would need an absolute overlay measured against the markers, + which drifts as soon as a label wraps. */} + + + + + {done ? ( + + ) : ( + + {index + 1} + + )} + + + + + + + {label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + step: { + flex: 1, + alignItems: 'center', + gap: 7, + }, + railRow: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'stretch', + }, + rail: { + flex: 1, + height: 1, + }, + marker: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 1.5, + alignItems: 'center', + justifyContent: 'center', + }, + markerText: { + fontSize: 11, + lineHeight: 14, + letterSpacing: 0, + }, + label: { + fontSize: 12, + lineHeight: 16, + textAlign: 'center', + paddingHorizontal: 2, + }, +}); + +export const Stepper = memo(StepperComponent); diff --git a/src/components/ToastHost/index.tsx b/src/components/ToastHost/index.tsx index e6bb8ca..fd9bd5e 100644 --- a/src/components/ToastHost/index.tsx +++ b/src/components/ToastHost/index.tsx @@ -10,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { selectToasts } from '../../modules/ui/store/uiSelectors'; import { uiActions } from '../../modules/ui/store/uiSlice'; -import { useTheme } from '../../theme'; +import { useTheme, fontFamily } from '../../theme'; interface ToastCardProps { id: string; @@ -106,7 +106,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 14, - fontWeight: '700', + fontFamily: fontFamily.bold, }, description: { fontSize: 12, diff --git a/src/components/TransactionItem/index.tsx b/src/components/TransactionItem/index.tsx index 99373f8..0f6137c 100644 --- a/src/components/TransactionItem/index.tsx +++ b/src/components/TransactionItem/index.tsx @@ -7,7 +7,7 @@ import { import Ionicons from '@react-native-vector-icons/ionicons'; import type { TransactionRecord } from '../../types/app'; -import { useTheme } from '../../theme'; +import { useTheme, textStyles } from '../../theme'; import { formatCurrency, formatDate } from '../../utils/formatters'; interface TransactionItemProps { @@ -28,48 +28,59 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => { }, [item.id]); const riskConfig = { - safe: { color: theme.colors.success, label: 'SECURE', icon: 'shield-checkmark' as const }, - medium: { color: theme.colors.warning, label: 'FLAGGED', icon: 'warning' as const }, - high: { color: theme.colors.danger, label: 'HIGH RISK', icon: 'alert-circle' as const }, + safe: { + color: theme.colors.success, + surface: theme.colors.successSurface, + border: theme.colors.successBorder, + label: 'SECURE', + icon: 'shield-checkmark' as const, + }, + medium: { + color: theme.colors.warning, + surface: theme.colors.warningSurface, + border: theme.colors.warningBorder, + label: 'FLAGGED', + icon: 'warning' as const, + }, + high: { + color: theme.colors.danger, + surface: theme.colors.dangerSurface, + border: theme.colors.dangerBorder, + label: 'HIGH RISK', + icon: 'alert-circle' as const, + }, }[riskLevel]; return ( - + - {item.title} + {item.title} - + {item.counterparty} - - + + {formatDate(item.occurredAt)} @@ -80,13 +91,18 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => { {isCredit ? '+' : '-'} {formatCurrency(item.amount)} - + {riskConfig.label} @@ -125,9 +141,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, title: { - fontSize: 16, - fontWeight: '800', - letterSpacing: -0.3, + ...textStyles.h3, }, subtitleRow: { flexDirection: 'row', @@ -138,20 +152,17 @@ const styles = StyleSheet.create({ width: 3, height: 3, borderRadius: 1.5, - backgroundColor: 'rgba(255, 255, 255, 0.2)', + backgroundColor: 'transparent', // set from the theme at the call site }, subtitle: { - fontSize: 12, - fontWeight: '500', + ...textStyles.caption, }, trailing: { alignItems: 'flex-end', gap: 4, }, amount: { - fontSize: 16, - fontWeight: '800', - letterSpacing: -0.2, + ...textStyles.money, }, riskBadge: { paddingHorizontal: 6, @@ -159,9 +170,17 @@ const styles = StyleSheet.create({ borderRadius: 6, borderWidth: 1, }, + // Takes its family and casing from `eyebrow`, then overrides the size. + // + // 8pt is below the scale's floor of 11 and the scale has no entry for it on + // purpose — at this size the label is decoration, not something a customer + // can be expected to read. Left at 8 rather than snapped up to 11 because + // the badge is laid out around it; worth revisiting as a design question, + // since a risk flag nobody can read is not doing its job. riskText: { + ...textStyles.eyebrow, fontSize: 8, - fontWeight: '900', + lineHeight: 11, letterSpacing: 0.5, }, }); diff --git a/src/components/index.ts b/src/components/index.ts index 43af040..0bb3913 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,15 +1,21 @@ +export * from './ActivityTracker'; +export * from './AppearanceSetting'; +export * from './AccountSelector'; export * from './AppHeader'; -export * from './BalanceCard'; +export * from './AccountCard'; export * from './BottomSheet'; export * from './Button'; export * from './Card'; export * from './DatePicker'; export * from './EmptyState'; +export * from './InfoBanner'; export * from './Input'; export * from './KeyboardAwareScrollView'; export * from './KeyboardSafeView'; export * from './Loader'; export * from './Modal'; export * from './ScreenShell'; +export * from './Stepper'; +export * from './SegmentedControl'; export * from './ToastHost'; export * from './TransactionItem'; diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index fecef21..74369e4 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -6,6 +6,28 @@ export const appConfig = { security: { sessionTimeoutMs: env.sessionTimeoutMs, biometricPromptTitle: 'Verify your identity', + /** + * Every launch asks for the MPIN (or biometrics) instead of resuming a + * stored session. There is no local way to check an MPIN — the only + * verifier is `POST /auth/login` — so "ask again" necessarily means + * "open a fresh session", which is exactly what signing in does. + * + * Set false to resume a still-valid session on launch instead, which is + * quicker but leaves the app open to whoever holds the unlocked handset. + */ + requireMpinOnLaunch: true, + /** + * How long the app may sit in the background before returning to it asks + * for the MPIN again. On Android the process usually survives being + * backgrounded, so without this "ask on every open" would only hold for a + * cold start. + * + * The grace period exists because a few seconds away is almost always the + * customer fetching something — an account number from a message, an SMS — + * and locking them out of their own transfer mid-flow is its own harm. Set + * to 0 to lock on every single return to the foreground. + */ + lockAfterBackgroundMs: 30_000, }, features: { useMockServer: env.useMockServer, diff --git a/src/config/env.ts b/src/config/env.ts index ed8b52f..13f9a7e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,46 @@ +/** + * Host running the CBS backend. Swagger for it is at `/swagger-ui/index.html`. + * + * This has to be an address the *phone* can reach, which is not the same thing + * as one that works in a browser on the dev machine: a VM-only or + * different-subnet address resolves fine on the laptop and is simply invisible + * to the handset, and that failure reads like the app being broken. `localhost` + * is wrong for the same reason — on a device it means the device itself. + * + * This is a named host on an internal domain, not an IP, so it resolves only + * where the DNS that serves `.lincpay` is reachable — the office network or + * the VPN. A handset on mobile data, or on a guest network without that + * resolver, fails to resolve it at all and the app looks like the backend is + * down. Note there is no port here: this host answers on the default 80, unlike + * the `:8089` LAN addresses this build was aimed at before. + * + * Plain HTTP, so it depends on cleartext traffic being allowed for this host + * specifically. Both platforms gate that by host and neither tells the app why + * a blocked request failed, so a change here has to be checked against: + * + * - here, + * - `android/app/src/main/res/xml/network_security_config.xml`, + * - `NSExceptionDomains` in `ios/BankCBS/Info.plist` — a bare IP literal is + * already covered there by `NSAllowsLocalNetworking`, but a *named* host + * is not and needs its own entry. + */ +const API_ORIGIN = 'http://mobile-banking-backend.lincpay'; + export const env = { - apiBaseUrl: 'https://sandbox.bankcbs.dev/v1', + /** CBS backend origin. */ + apiOrigin: API_ORIGIN, + /** Every documented endpoint lives under this prefix. */ + apiBaseUrl: `${API_ORIGIN}/api/v1`, + /** Organisation the app is branded for; sent at registration and at sign-in. */ + orgId: 'ORG001', + /** + * Modules with no backend behind them yet — cards, loans, insurance, deposits, + * the dashboard and the agent app — still resolve from fixtures. Registration, + * sign-in, accounts/MMID and payees always call the live API regardless. + */ useMockServer: true, - sessionTimeoutMs: 300000, + /** Server-enforced idle window; any authenticated call refreshes it. */ + sessionTimeoutMs: 300_000, + /** How long before the idle cut-off the user is warned. */ + sessionWarningMs: 60_000, }; diff --git a/src/constants/featureKeys.ts b/src/constants/featureKeys.ts new file mode 100644 index 0000000..dbb6fcc --- /dev/null +++ b/src/constants/featureKeys.ts @@ -0,0 +1,325 @@ +/** + * Centralised feature keys for the whole application. + * + * Every gateable thing in the app — module, screen, tab, menu entry, button and + * action — has exactly one key here. UI code must never pass a raw string to the + * feature service; always reference a member of this enum so renames and typos + * are caught by the compiler. + * + * Keys use dot notation and the hierarchy is encoded in the key itself: + * + * loans → parent of + * loans.apply → parent of + * loans.apply.submit + * + * The feature service derives parent/child relationships from this path, so a + * disabled parent automatically disables everything beneath it. Adding a child + * key therefore only requires that its parent key also exists. + */ +export enum FeatureKey { + // ---------------------------------------------------------------- Auth ---- + AUTH = 'auth', + AUTH_LOGIN = 'auth.login', + AUTH_LOGIN_USER_ROLE = 'auth.login.userRole', + AUTH_LOGIN_AGENT_ROLE = 'auth.login.agentRole', + AUTH_LOGIN_BIOMETRIC = 'auth.login.biometric', + AUTH_LOGIN_FORGOT_PASSWORD = 'auth.login.forgotPassword', + AUTH_REGISTER = 'auth.register', + AUTH_LOGIN_CREATE_ACCOUNT = 'auth.login.createAccount', + AUTH_LOGOUT = 'auth.logout', + + // ---------------------------------------------------------------- Home ---- + HOME = 'home', + HOME_ACCOUNTS = 'home.accounts', + HOME_ACCOUNTS_CAROUSEL = 'home.accounts.carousel', + HOME_ACCOUNTS_BALANCE_TOGGLE = 'home.accounts.balanceToggle', + HOME_ACCOUNTS_MINI_STATEMENT = 'home.accounts.miniStatement', + HOME_ACCOUNTS_DETAILS = 'home.accounts.details', + HOME_ACCOUNTS_EPASSBOOK = 'home.accounts.ePassbook', + HOME_ACCOUNTS_REKYC = 'home.accounts.reKyc', + HOME_ACCOUNTS_VIDEO_KYC = 'home.accounts.videoKyc', + HOME_ACCOUNTS_MMID = 'home.accounts.mmid', + + HOME_QUICK_ACTIONS = 'home.quickActions', + HOME_QUICK_ACTIONS_TRANSFER = 'home.quickActions.transfer', + HOME_QUICK_ACTIONS_BILL_PAY = 'home.quickActions.billPay', + HOME_QUICK_ACTIONS_AIRTIME = 'home.quickActions.airtime', + HOME_QUICK_ACTIONS_LOANS = 'home.quickActions.loans', + HOME_QUICK_ACTIONS_CARDS = 'home.quickActions.cards', + HOME_QUICK_ACTIONS_STATS = 'home.quickActions.stats', + + HOME_SERVICES = 'home.services', + HOME_SERVICES_TRANSFER = 'home.services.transfer', + HOME_SERVICES_SELF_TRANSFER = 'home.services.selfTransfer', + HOME_SERVICES_BILL_PAY = 'home.services.billPay', + HOME_SERVICES_COLLECT_REQUEST = 'home.services.collectRequest', + HOME_SERVICES_MANAGE_PAYEE = 'home.services.managePayee', + HOME_SERVICES_SUPPORT = 'home.services.support', + HOME_SERVICES_INSURANCE = 'home.services.insurance', + HOME_SERVICES_DEPOSITS = 'home.services.deposits', + HOME_SERVICES_GOLD_LOAN = 'home.services.goldLoan', + + HOME_INSIGHTS = 'home.insights', + HOME_PROMO_BANNER = 'home.promoBanner', + + // -------------------------------------------------------------- Search ---- + SEARCH = 'search', + SEARCH_GLOBAL = 'search.global', + + // ------------------------------------------------------- Notifications ---- + NOTIFICATIONS = 'notifications', + NOTIFICATIONS_LIST = 'notifications.list', + NOTIFICATIONS_MARK_READ = 'notifications.markRead', + + // ------------------------------------------------------------ Payments ---- + PAYMENTS = 'payments', + PAYMENTS_HUB = 'payments.hub', + PAYMENTS_BENEFICIARIES = 'payments.beneficiaries', + PAYMENTS_BENEFICIARIES_LIST = 'payments.beneficiaries.list', + PAYMENTS_BENEFICIARIES_ADD = 'payments.beneficiaries.add', + PAYMENTS_BENEFICIARIES_DELETE = 'payments.beneficiaries.delete', + PAYMENTS_TRANSFER = 'payments.transfer', + PAYMENTS_TRANSFER_IMPS = 'payments.transfer.imps', + PAYMENTS_TRANSFER_NEFT = 'payments.transfer.neft', + PAYMENTS_TRANSFER_RTGS = 'payments.transfer.rtgs', + PAYMENTS_TRANSFER_INTERNAL = 'payments.transfer.internal', + PAYMENTS_TRANSFER_SUBMIT = 'payments.transfer.submit', + /** + * The three fund-transfer entry points on the payments hub. Each is a + * destination in its own right rather than a rail: `mobile` collects a + * mobile number and MMID, `account` an account number and IFSC, and `own` + * moves money + * between the customer's own accounts. The rail keys above still decide how + * the resulting transfer settles. + */ + PAYMENTS_TRANSFER_MOBILE = 'payments.transfer.mobile', + PAYMENTS_TRANSFER_ACCOUNT = 'payments.transfer.account', + PAYMENTS_TRANSFER_OWN = 'payments.transfer.own', + PAYMENTS_UPI = 'payments.upi', + PAYMENTS_UPI_SCAN = 'payments.upi.scan', + + /** This customer's own MMIDs, for receiving money over IMPS. */ + PAYMENTS_MMID = 'payments.mmid', + PAYMENTS_MMID_CANCEL = 'payments.mmid.cancel', + PAYMENTS_MMID_GENERATE = 'payments.mmid.generate', + + // --------------------------------------------------------------- Cards ---- + CARDS = 'cards', + CARDS_OVERVIEW = 'cards.overview', + CARDS_DETAILS = 'cards.details', + CARDS_TRANSACTIONS = 'cards.transactions', + CARDS_CONTROLS = 'cards.controls', + CARDS_CONTROLS_FREEZE = 'cards.controls.freeze', + CARDS_CONTROLS_ONLINE = 'cards.controls.online', + CARDS_CONTROLS_INTERNATIONAL = 'cards.controls.international', + CARDS_CONTROLS_CONTACTLESS = 'cards.controls.contactless', + CARDS_CONTROLS_ATM = 'cards.controls.atm', + CARDS_CONTROLS_LIMITS = 'cards.controls.limits', + CARDS_CONTROLS_BLOCK = 'cards.controls.block', + CARDS_APPLY_CREDIT = 'cards.applyCredit', + CARDS_REQUEST_PHYSICAL = 'cards.requestPhysical', + + // ------------------------------------------------------------ Services ---- + SERVICES = 'services', + SERVICES_HUB = 'services.hub', + + SERVICES_DEPOSITS = 'services.deposits', + SERVICES_DEPOSITS_OVERVIEW = 'services.deposits.overview', + SERVICES_DEPOSITS_DETAILS = 'services.deposits.details', + SERVICES_DEPOSITS_OPEN_FD = 'services.deposits.openFd', + SERVICES_DEPOSITS_OPEN_RD = 'services.deposits.openRd', + SERVICES_DEPOSITS_CALCULATOR = 'services.deposits.calculator', + + SERVICES_CHEQUE = 'services.cheque', + SERVICES_CHEQUE_HUB = 'services.cheque.hub', + SERVICES_CHEQUE_BOOK_REQUEST = 'services.cheque.bookRequest', + SERVICES_CHEQUE_STOP_PAYMENT = 'services.cheque.stopPayment', + SERVICES_CHEQUE_TRACKING = 'services.cheque.tracking', + SERVICES_CHEQUE_POSITIVE_PAY = 'services.cheque.positivePay', + + SERVICES_CERTIFICATES = 'services.certificates', + SERVICES_CERTIFICATES_HUB = 'services.certificates.hub', + SERVICES_CERTIFICATES_LIST = 'services.certificates.list', + SERVICES_CERTIFICATES_BALANCE = 'services.certificates.balance', + SERVICES_CERTIFICATES_INTEREST = 'services.certificates.interest', + SERVICES_CERTIFICATES_SHARE = 'services.certificates.share', + SERVICES_CERTIFICATES_HYPOTHECATION = 'services.certificates.hypothecation', + SERVICES_CERTIFICATES_INSURANCE = 'services.certificates.insurance', + SERVICES_CERTIFICATES_TAX = 'services.certificates.tax', + SERVICES_CERTIFICATES_LOAN = 'services.certificates.loan', + + SERVICES_REQUESTS = 'services.requests', + SERVICES_REQUESTS_HUB = 'services.requests.hub', + + SERVICES_STANDING_INSTRUCTIONS = 'services.standingInstructions', + SERVICES_FORM_15G15H = 'services.form15g15h', + SERVICES_NOMINATION = 'services.nomination', + SERVICES_BRANCH_LOCATOR = 'services.branchLocator', + + SERVICES_AWARENESS = 'services.awareness', + SERVICES_AWARENESS_LIST = 'services.awareness.list', + SERVICES_AWARENESS_DETAIL = 'services.awareness.detail', + + SERVICES_PROTECTION = 'services.protection', + SERVICES_PROTECTION_HUB = 'services.protection.hub', + SERVICES_PROTECTION_SCHEME_DETAIL = 'services.protection.schemeDetail', + SERVICES_PROTECTION_ENROLLMENT = 'services.protection.enrollment', + SERVICES_PROTECTION_DEAF = 'services.protection.deaf', + SERVICES_PROTECTION_DEAF_CLAIM = 'services.protection.deafClaim', + SERVICES_PROTECTION_DICGC = 'services.protection.dicgc', + + SERVICES_SUPPORT_CHAT = 'services.supportChat', + SERVICES_HELP_CENTER = 'services.helpCenter', + + /** + * Enquiry desk: the entries that answer a question outright, or change a PIN, + * rather than opening a journey. + * + * `mpinChange` and `miniStatement` gate entry points rather than screens — + * those screens live in the profile and home stacks and keep their own keys. + * Having a key here lets the bank take the tile off the service desk without + * also removing the screen from where it already was. + */ + SERVICES_BALANCE_ENQUIRY = 'services.balanceEnquiry', + SERVICES_MINI_STATEMENT = 'services.miniStatement', + SERVICES_MPIN_CHANGE = 'services.mpinChange', + SERVICES_TPIN_CHANGE = 'services.tpinChange', + SERVICES_FATE_OF_CHEQUE = 'services.fateOfCheque', + + // --------------------------------------------------------------- Loans ---- + LOANS = 'loans', + LOANS_DASHBOARD = 'loans.dashboard', + LOANS_TYPES = 'loans.types', + LOANS_ELIGIBILITY = 'loans.eligibility', + LOANS_CALCULATOR = 'loans.calculator', + LOANS_APPLY = 'loans.apply', + LOANS_TRACKING = 'loans.tracking', + LOANS_ACTIVE_DETAILS = 'loans.activeDetails', + LOANS_EMI_PAYMENT = 'loans.emiPayment', + LOANS_FORECLOSURE = 'loans.foreclosure', + LOANS_HISTORY = 'loans.history', + LOANS_DOCUMENTS = 'loans.documents', + + // ----------------------------------------------------------- Insurance ---- + INSURANCE = 'insurance', + INSURANCE_DASHBOARD = 'insurance.dashboard', + INSURANCE_BUY = 'insurance.buy', + INSURANCE_POLICIES = 'insurance.policies', + INSURANCE_POLICY_DETAILS = 'insurance.policyDetails', + INSURANCE_RENEWALS = 'insurance.renewals', + INSURANCE_CLAIMS = 'insurance.claims', + INSURANCE_DOCUMENTS = 'insurance.documents', + INSURANCE_CALCULATOR = 'insurance.calculator', + INSURANCE_COMPARISON = 'insurance.comparison', + INSURANCE_NOTIFICATIONS = 'insurance.notifications', + + // ------------------------------------------------------------- Profile ---- + PROFILE = 'profile', + PROFILE_MAIN = 'profile.main', + PROFILE_EDIT = 'profile.edit', + PROFILE_ACCOUNTS = 'profile.accounts', + PROFILE_ACCOUNTS_ADD = 'profile.accounts.add', + PROFILE_BENEFICIARIES_SHORTCUT = 'profile.beneficiariesShortcut', + PROFILE_STATEMENTS_SHORTCUT = 'profile.statementsShortcut', + + PROFILE_SETTINGS = 'profile.settings', + PROFILE_SETTINGS_LANGUAGE = 'profile.settings.language', + PROFILE_SETTINGS_NOTIFICATIONS = 'profile.settings.notifications', + PROFILE_SETTINGS_PRIVACY = 'profile.settings.privacy', + + PROFILE_SECURITY = 'profile.security', + PROFILE_SECURITY_CHANGE_PIN = 'profile.security.changePin', + PROFILE_SECURITY_CHANGE_PASSWORD = 'profile.security.changePassword', + PROFILE_SECURITY_BIOMETRIC = 'profile.security.biometric', + PROFILE_SECURITY_TWO_FACTOR = 'profile.security.twoFactor', + PROFILE_SECURITY_DEVICES = 'profile.security.devices', + PROFILE_SECURITY_LOGOUT_ALL = 'profile.security.logoutAll', + PROFILE_SECURITY_HIDE_BALANCE = 'profile.security.hideBalance', + + PROFILE_SUPPORT = 'profile.support', + PROFILE_LEGAL = 'profile.legal', + PROFILE_ABOUT = 'profile.about', + /** + * In-app switchboard for the curated operational features. + * + * Ships disabled and is meant to be turned on through remote config for the + * tenants whose staff need it — so the bank decides which installs expose it, + * rather than it being present for every customer. + */ + PROFILE_FEATURE_FLAGS = 'profile.featureFlags', + + // --------------------------------------------------------------- Agent ---- + AGENT = 'agent', + + AGENT_DASHBOARD = 'agent.dashboard', + AGENT_DASHBOARD_MAIN = 'agent.dashboard.main', + AGENT_DASHBOARD_ROUTE = 'agent.dashboard.route', + + AGENT_CUSTOMERS = 'agent.customers', + AGENT_CUSTOMERS_LIST = 'agent.customers.list', + AGENT_CUSTOMERS_DETAIL = 'agent.customers.detail', + AGENT_CUSTOMERS_CREATE = 'agent.customers.create', + AGENT_CUSTOMERS_EDIT = 'agent.customers.edit', + AGENT_CUSTOMERS_DELETE = 'agent.customers.delete', + AGENT_CUSTOMERS_INSTALLMENT_HISTORY = 'agent.customers.installmentHistory', + + AGENT_COLLECTIONS = 'agent.collections', + AGENT_COLLECTIONS_DAILY = 'agent.collections.daily', + AGENT_COLLECTIONS_HISTORY = 'agent.collections.history', + AGENT_COLLECTIONS_RECEIPT = 'agent.collections.receipt', + + AGENT_LOANS = 'agent.loans', + AGENT_LOANS_LIST = 'agent.loans.list', + AGENT_LOANS_CREATE_REQUEST = 'agent.loans.createRequest', + AGENT_LOANS_ACCOUNT_OPENING = 'agent.loans.accountOpening', + AGENT_LOANS_COMMISSION = 'agent.loans.commission', + + AGENT_PROFILE = 'agent.profile', + AGENT_PROFILE_MAIN = 'agent.profile.main', + AGENT_PROFILE_SMS_HISTORY = 'agent.profile.smsHistory', + AGENT_PROFILE_CHANGE_PASSWORD = 'agent.profile.changePassword', +} + +/** Every declared feature key, in declaration order. */ +export const ALL_FEATURE_KEYS = Object.values(FeatureKey) as FeatureKey[]; + +/** Set form, for O(1) membership checks when validating untrusted strings. */ +const FEATURE_KEY_SET: ReadonlySet = new Set(ALL_FEATURE_KEYS); + +/** + * Narrows an arbitrary string (remote config, JSON-driven UI, deep link) to a + * known FeatureKey. Returns `undefined` when the string is not a declared key, + * which callers must treat as "not enabled". + */ +export const toFeatureKey = (value: string | null | undefined): FeatureKey | undefined => + typeof value === 'string' && FEATURE_KEY_SET.has(value) ? (value as FeatureKey) : undefined; + +/** + * Parent key of a feature, derived from its dot path. + * `loans.apply.submit` → `loans.apply`; a root key → `undefined`. + */ +export const getParentFeatureKey = (key: FeatureKey): FeatureKey | undefined => { + const lastDot = key.lastIndexOf('.'); + + if (lastDot === -1) { + return undefined; + } + + return toFeatureKey(key.slice(0, lastDot)); +}; + +/** + * All ancestors of a key, nearest parent first. + * `loans.apply.submit` → [`loans.apply`, `loans`]. + */ +export const getFeatureAncestors = (key: FeatureKey): FeatureKey[] => { + const ancestors: FeatureKey[] = []; + let current = getParentFeatureKey(key); + + while (current) { + ancestors.push(current); + current = getParentFeatureKey(current); + } + + return ancestors; +}; diff --git a/src/constants/legalContent.ts b/src/constants/legalContent.ts new file mode 100644 index 0000000..0995e21 --- /dev/null +++ b/src/constants/legalContent.ts @@ -0,0 +1,88 @@ +/** + * The bank's identity and its legal copy, in one place. + * + * Both the pre-auth sign-in footer and the in-app Legal & Information screen + * read from here. They used to carry their own wording, which is how an app + * ends up telling a customer two different things about the same policy — and + * the pre-auth copy is the version a regulator looks at first, because it is + * the one shown before anybody agrees to anything. + * + * Legal wording is not developer copy. Anything in `terms` or `privacy` should + * come from whoever signs off on it at the bank; the placeholder text below is + * scaffolding and is marked as such. + */ + +export const company = { + /** Full registered name. Used wherever the entity has to be named exactly. */ + legalName: 'CBS Bank Limited', + /** Conversational name, for running text. */ + shortName: 'CBS Bank', + /** Shown under the sign-in form; a bank is expected to name its regulator. */ + regulator: 'Regulated by the Reserve Bank of India', + /** Appears as "© ". */ + copyrightFrom: 2026, +} as const; + +/** + * The current year, so the sign-in footer does not quietly go stale every + * January. Computed per call rather than at module load: the process can + * outlive midnight on a handset that is never fully closed. + */ +export const copyrightLine = () => { + const now = new Date().getFullYear(); + const from = company.copyrightFrom; + // A range only once there is a range to show. + return `© ${now > from ? `${from}–${now}` : from} ${company.legalName}`; +}; + +export type LegalSection = { + heading: string; + body: string[]; +}; + +/** PLACEHOLDER — replace with the bank's approved terms before release. */ +export const terms: LegalSection[] = [ + { + heading: 'Using this app', + body: [ + `By signing in to the ${company.shortName} mobile app you agree to these terms and to the privacy policy.`, + 'You are responsible for keeping your MPIN, passwords and registered device secure. Do not share your MPIN with anyone, including anybody claiming to be from the bank — we will never ask for it.', + ], + }, + { + heading: 'Transfers and payments', + body: [ + 'Real-time transfers such as IMPS and UPI are irreversible once processed. Check the beneficiary details before you confirm a payment.', + 'Transaction limits are set by the bank and by the relevant payment network, and may change without notice.', + ], + }, + { + heading: 'Availability', + body: [ + 'Services may be unavailable during maintenance or for reasons outside the bank’s control. The bank is not liable for losses arising from an interruption it could not reasonably prevent.', + ], + }, +]; + +/** PLACEHOLDER — replace with the bank's approved privacy policy before release. */ +export const privacy: LegalSection[] = [ + { + heading: 'What we collect', + body: [ + 'Identity and contact details, account and transaction records, and technical information about the device you sign in from — including its SIM-registered mobile number, which is what links this app to your account.', + ], + }, + { + heading: 'Why we collect it', + body: [ + 'To operate your accounts, process payments, detect fraud, and meet the KYC and record-keeping obligations placed on the bank by law.', + 'Biometric data never leaves your device. Fingerprint and face unlock are verified by the handset itself; the bank receives only the result.', + ], + }, + { + heading: 'Your choices', + body: [ + 'You can ask for a copy of the personal data the bank holds about you, or ask for it to be corrected, by contacting your branch.', + ], + }, +]; diff --git a/src/constants/mockData.ts b/src/constants/mockData.ts index 4eb9070..c614a83 100644 --- a/src/constants/mockData.ts +++ b/src/constants/mockData.ts @@ -32,6 +32,7 @@ export const demoUser: UserProfile = { phone: '+91 98765 43210', customerId: '0090421877', kycStatus: 'verified', + lastKycDate: '2025-04-18', ckycNumber: '10041285694218', language: 'en', biometricEnabled: true, @@ -99,6 +100,8 @@ export const demoAccounts: AccountSummary[] = [ type: 'Savings', accountHolder: 'Aarav Sharma', branch: 'Bengaluru Koramangala', + ifscCode: 'CBSB0001001', + status: 'Active', maskedNumber: 'XXXX 9312', availableBalance: 248560.78, currentBalance: 251040.78, @@ -110,6 +113,8 @@ export const demoAccounts: AccountSummary[] = [ type: 'Salary', accountHolder: 'Aarav Sharma', branch: 'Bengaluru Tech Park', + ifscCode: 'CBSB0001002', + status: 'Active', maskedNumber: 'XXXX 7081', availableBalance: 82450, currentBalance: 82450, @@ -625,6 +630,19 @@ export const demoLoanProducts: LoanProduct[] = [ description: 'Fund your higher education dreams in India or abroad.', features: ['Zero processing fee', 'Moratorium period', 'Tax benefits u/s 80E', 'Covers tuition & living'], }, + { + id: 'loan_dds', + name: 'Loan Against DDS Deposit', + category: 'Loan Against DDS Deposit', + interestRate: 9, + maxAmount: 2000000, + minAmount: 10000, + tenureMonths: 36, + minTenureMonths: 6, + processingFeePercent: 0.5, + description: 'Borrow against your Daily Deposit Scheme balance while your deposit keeps earning interest.', + features: ['Up to 90% of deposit value', 'No income proof needed', 'Deposit keeps earning', 'Quick disbursal'], + }, ]; export const demoActiveLoans: ActiveLoan[] = [ diff --git a/src/constants/queryTags.ts b/src/constants/queryTags.ts index 6aa5f6a..85832c9 100644 --- a/src/constants/queryTags.ts +++ b/src/constants/queryTags.ts @@ -8,6 +8,7 @@ export const QUERY_TAGS = [ 'Payments', 'Deposits', 'Services', + 'ChequeBooks', 'Loans', 'LoanActive', 'LoanApplications', diff --git a/src/features/FeatureGate.tsx b/src/features/FeatureGate.tsx new file mode 100644 index 0000000..d1ad37f --- /dev/null +++ b/src/features/FeatureGate.tsx @@ -0,0 +1,103 @@ +import { useMemo, type ReactElement, type ReactNode } from 'react'; + +import type { FeatureKey } from '../constants/featureKeys'; +import { featureService, isFeatureEnabled } from './featureService'; +import { useFeatureVersion } from './useFeature'; + +type GateMode = 'all' | 'any'; + +/** + * Resolves a set of keys against the current config, re-evaluating whenever a + * source layer changes. An empty key list resolves to `false` (fail closed). + */ +const useGateResult = (keys: readonly FeatureKey[], mode: GateMode): boolean => { + const version = useFeatureVersion(); + const signature = keys.join('|'); + + return useMemo(() => { + if (keys.length === 0) { + return false; + } + + return mode === 'any' + ? keys.some((key) => isFeatureEnabled(key)) + : keys.every((key) => isFeatureEnabled(key)); + // `signature` stands in for `keys` so an inline array literal does not thrash the memo. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature, mode, version]); +}; + +type FeatureGateProps = { + /** Feature(s) guarding this subtree. */ + feature: FeatureKey | readonly FeatureKey[]; + /** + * How to combine multiple keys. `all` (default) requires every key; `any` + * requires at least one. Irrelevant for a single key. + */ + mode?: GateMode; + children: ReactNode; +}; + +/** + * Renders its children only while the feature is enabled, and renders **nothing** + * otherwise. + * + * + * + * + * + * There is deliberately no `fallback` / placeholder prop. A disabled feature must + * leave no trace in the UI — no greyed-out control, no "coming soon" copy, no + * hidden-but-present node — so the only two outcomes are the children or `null`. + * + * This hides UI; it does not neuter a handler some other path still holds. Guard + * the handler with `useFeatureAction` / `guardFeature`, and the screen with + * `FeatureRoute`. + */ +export const FeatureGate = ({ + feature, + mode = 'all', + children, +}: FeatureGateProps): ReactElement | null => { + const keys = useMemo( + () => (Array.isArray(feature) ? (feature as FeatureKey[]) : [feature as FeatureKey]), + [feature], + ); + + return useGateResult(keys, mode) ? <>{children} : null; +}; + +type DynamicFeatureGateProps = { + /** + * Feature reference as a raw string — from JSON-driven UI or a remote payload. + * An unknown string hides the subtree; omitting the prop leaves it visible, so + * untagged components render exactly as before. + */ + feature?: string; + children: ReactNode; +}; + +/** + * String-keyed gate for JSON-driven UI, where the feature name arrives as data + * rather than as a compile-time constant: + * + * { "type": "button", "label": "Create User", "feature": "agent.customers.create" } + * + * Prefer `FeatureGate` with a `FeatureKey` wherever the key is known at compile + * time; this exists only for genuinely dynamic content. + */ +export const DynamicFeatureGate = ({ + feature, + children, +}: DynamicFeatureGateProps): ReactElement | null => { + const version = useFeatureVersion(); + + const enabled = useMemo( + () => feature === undefined || featureService.isEnabledKey(feature), + // `version` is the signal that the service's mutable state changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + [feature, version], + ); + + return enabled ? <>{children} : null; +}; diff --git a/src/features/FeatureProvider.tsx b/src/features/FeatureProvider.tsx new file mode 100644 index 0000000..4051b89 --- /dev/null +++ b/src/features/FeatureProvider.tsx @@ -0,0 +1,159 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, + type PropsWithChildren, +} from 'react'; + +import type { FeatureKey } from '../constants/featureKeys'; +import { featureService, isFeatureEnabled } from './featureService'; +import { FeatureSource, type FeatureOverrides } from './types'; +import { useFeatureVersion } from './useFeature'; + +type FeatureContextValue = { + /** Imperative check, for callbacks that should not subscribe to re-renders. */ + isEnabled: (key: FeatureKey) => boolean; + /** False until remote/tenant sources have been resolved (or skipped). */ + ready: boolean; + /** Bumps whenever any source layer changes. */ + version: number; +}; + +const FeatureContext = createContext(undefined); + +type FeatureProviderProps = PropsWithChildren<{ + /** + * Loads the remote layer (Firebase Remote Config, config API, …). Resolving to + * `undefined` leaves the layer untouched. Failures are swallowed so the app + * always falls back to the compiled-in defaults rather than blocking startup. + */ + loadRemoteOverrides?: () => Promise; + /** Loads per-tenant/role overrides once the session is known. */ + loadTenantOverrides?: () => Promise; + /** + * Applies configuration that the one-loader-per-layer slots above cannot + * express, and runs *before* them. + * + * Two cases need it. The CBS config endpoint returns the org-wide and + * role-scoped switches in a single response, so one fetch has to write two + * layers; and the persisted local overrides have to be read back into the user + * layer, which is not a network load at all. Both own their own layer writes, + * so this returns nothing. + * + * Failures are swallowed here too — a feature layer that cannot be loaded must + * degrade to the layers beneath it, never block the app from starting. + */ + hydrate?: () => Promise; +}>; + +/** + * Wires the feature service into the React tree. + * + * The hooks work without this provider — they subscribe to the service directly + * — so mounting it is only required for two things: resolving the async source + * layers at startup, and exposing `ready` for callers that want to hold UI back + * until remote config has landed. + * + * In development it also validates the static config and logs any key that is + * declared without config, or whose dot path implies a parent that does not + * exist (which would silently make it un-gateable by that parent). + */ +export const FeatureProvider = ({ + children, + loadRemoteOverrides, + loadTenantOverrides, + hydrate, +}: FeatureProviderProps) => { + const version = useFeatureVersion(); + const [ready, setReady] = useState(!loadRemoteOverrides && !loadTenantOverrides && !hydrate); + + useEffect(() => { + if (!__DEV__) { + return; + } + + const issues = featureService.validate(); + + if (issues.length > 0) { + console.warn( + `[features] ${issues.length} configuration issue(s):\n` + + issues.map((issue) => ` • [${issue.problem}] ${issue.detail}`).join('\n'), + ); + } + }, []); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + if (hydrate) { + try { + await hydrate(); + } catch (error) { + if (__DEV__) { + console.warn('[features] Hydration failed; using defaults.', error); + } + } + } + + const layers: Array<[FeatureSource, (() => Promise) | undefined]> = + [ + [FeatureSource.Remote, loadRemoteOverrides], + [FeatureSource.Tenant, loadTenantOverrides], + ]; + + for (const [source, loader] of layers) { + if (!loader) { + continue; + } + + try { + const overrides = await loader(); + + if (!cancelled && overrides) { + featureService.setSource(source, overrides); + } + } catch (error) { + if (__DEV__) { + console.warn(`[features] Failed to load "${source}" overrides; using defaults.`, error); + } + } + } + + if (!cancelled) { + setReady(true); + } + }; + + // `run` swallows its own failures, so there is nothing to catch here. + run(); + + return () => { + cancelled = true; + }; + }, [loadRemoteOverrides, loadTenantOverrides, hydrate]); + + const value = useMemo( + () => ({ isEnabled: isFeatureEnabled, ready, version }), + [ready, version], + ); + + return {children}; +}; + +/** + * Access to the feature context. Throws when used outside the provider, which + * catches wiring mistakes early — most components should use `useFeature` + * instead, which needs no provider. + */ +export const useFeatureContext = (): FeatureContextValue => { + const context = useContext(FeatureContext); + + if (!context) { + throw new Error('useFeatureContext must be used within a .'); + } + + return context; +}; diff --git a/src/features/adminFeatures.ts b/src/features/adminFeatures.ts new file mode 100644 index 0000000..3f98830 --- /dev/null +++ b/src/features/adminFeatures.ts @@ -0,0 +1,65 @@ +import { FeatureKey } from '../constants/featureKeys'; + +/** + * The features the shipped admin switchboard may touch. + * + * The dev screen lists all 182 keys; this list is deliberately much shorter, + * because the two screens answer different questions. The dev screen is for + * "does gating work at all", and every key belongs there. This one is for a + * member of staff shutting a product down in the field, so it holds only the + * switches that make sense as an operational decision — product areas and the + * transactional actions inside them. + * + * Two kinds of key are kept off it on purpose: + * + * - **Anything under `auth`.** Turning off sign-in or registration from inside + * the app locks the handset out of the app, including out of this screen. That + * belongs to remote config, where it can be undone from the server. + * - **`profile.featureFlags` itself**, for the same reason: a switchboard that + * can switch itself off is a one-way door. + * + * Every entry is a real product decision, and each is grouped as it should + * appear in the UI. + */ +export const ADMIN_TOGGLEABLE_FEATURES: ReadonlyArray<{ + title: string; + keys: readonly FeatureKey[]; +}> = [ + { + title: 'Payments', + keys: [ + FeatureKey.PAYMENTS, + FeatureKey.PAYMENTS_TRANSFER, + FeatureKey.PAYMENTS_BENEFICIARIES_ADD, + ], + }, + { + title: 'Cards', + keys: [FeatureKey.CARDS, FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_APPLY_CREDIT], + }, + { + title: 'Lending & cover', + keys: [FeatureKey.LOANS, FeatureKey.LOANS_APPLY, FeatureKey.INSURANCE, FeatureKey.INSURANCE_BUY], + }, + { + title: 'Deposits & services', + keys: [ + FeatureKey.SERVICES, + FeatureKey.SERVICES_DEPOSITS, + FeatureKey.SERVICES_DEPOSITS_OPEN_FD, + FeatureKey.SERVICES_DEPOSITS_OPEN_RD, + ], + }, + { + title: 'Home & discovery', + keys: [ + FeatureKey.HOME_ACCOUNTS_MMID, + FeatureKey.SEARCH_GLOBAL, + FeatureKey.NOTIFICATIONS_LIST, + ], + }, + { + title: 'Field agent app', + keys: [FeatureKey.AGENT, FeatureKey.AGENT_COLLECTIONS], + }, +]; diff --git a/src/features/featureConfig.ts b/src/features/featureConfig.ts new file mode 100644 index 0000000..39025e5 --- /dev/null +++ b/src/features/featureConfig.ts @@ -0,0 +1,291 @@ +import { FeatureKey } from '../constants/featureKeys'; +import type { FeatureDefinition, FeatureOverrides } from './types'; + +/** + * Default feature configuration — the single source of truth compiled into the + * bundle. + * + * This is a flat `Record` rather than a nested object on purpose: + * the `Record` makes TypeScript reject the file if any declared key is missing + * config, which a nested object literal cannot guarantee. The hierarchy is not + * lost — it lives in the dot-notation keys themselves (`services.cheque.hub` is + * a child of `services.cheque`), and the service derives parents from that path. + * + * Rules: + * - Disabling a parent disables every descendant, whatever their own flag says. + * e.g. `services.cheque = false` hides the cheque hub, book request, stop + * payment, tracking and positive pay in one edit. + * - Entries are grouped and indented to read like the tree they represent. + */ +export const DEFAULT_FEATURE_CONFIG: Record = { + // ---------------------------------------------------------------- Auth ---- + [FeatureKey.AUTH]: { enabled: true, description: 'Authentication module' }, + [FeatureKey.AUTH_LOGIN]: { enabled: true }, + [FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' }, + [FeatureKey.AUTH_LOGIN_AGENT_ROLE]: { enabled: true, description: 'Agent login tab' }, + [FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true }, + [FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true }, + [FeatureKey.AUTH_REGISTER]: { + enabled: true, + description: 'Device registration: lookup, OTP, MPIN setup', + }, + [FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true }, + [FeatureKey.AUTH_LOGOUT]: { enabled: true }, + + // ---------------------------------------------------------------- Home ---- + [FeatureKey.HOME]: { enabled: true, description: 'Home tab' }, + [FeatureKey.HOME_ACCOUNTS]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_CAROUSEL]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_MINI_STATEMENT]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_DETAILS]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_EPASSBOOK]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_REKYC]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_VIDEO_KYC]: { enabled: true }, + [FeatureKey.HOME_ACCOUNTS_MMID]: { + enabled: true, + description: 'Accounts and MMID, for receiving money by IMPS', + }, + + [FeatureKey.HOME_QUICK_ACTIONS]: { enabled: true }, + [FeatureKey.HOME_QUICK_ACTIONS_TRANSFER]: { enabled: true }, + [FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY]: { enabled: true }, + [FeatureKey.HOME_QUICK_ACTIONS_AIRTIME]: { enabled: true }, + [FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: false, reason: 'Destination module is off' }, + [FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: false, reason: 'Destination module is off' }, + [FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true }, + + [FeatureKey.HOME_SERVICES]: { enabled: true }, + [FeatureKey.HOME_SERVICES_TRANSFER]: { enabled: true }, + [FeatureKey.HOME_SERVICES_SELF_TRANSFER]: { enabled: true }, + [FeatureKey.HOME_SERVICES_BILL_PAY]: { enabled: true }, + [FeatureKey.HOME_SERVICES_COLLECT_REQUEST]: { enabled: true }, + [FeatureKey.HOME_SERVICES_MANAGE_PAYEE]: { enabled: true }, + [FeatureKey.HOME_SERVICES_SUPPORT]: { enabled: true }, + [FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: false, reason: 'Destination module is off' }, + [FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true }, + [FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' }, + + [FeatureKey.HOME_INSIGHTS]: { enabled: true }, + [FeatureKey.HOME_PROMO_BANNER]: { enabled: true }, + + // -------------------------------------------------------------- Search ---- + [FeatureKey.SEARCH]: { enabled: true }, + [FeatureKey.SEARCH_GLOBAL]: { enabled: true }, + + // ------------------------------------------------------- Notifications ---- + [FeatureKey.NOTIFICATIONS]: { enabled: true }, + [FeatureKey.NOTIFICATIONS_LIST]: { enabled: true }, + [FeatureKey.NOTIFICATIONS_MARK_READ]: { enabled: true }, + + // ------------------------------------------------------------ Payments ---- + [FeatureKey.PAYMENTS]: { enabled: true, description: 'Payments tab' }, + [FeatureKey.PAYMENTS_HUB]: { enabled: true }, + [FeatureKey.PAYMENTS_BENEFICIARIES]: { enabled: true }, + [FeatureKey.PAYMENTS_BENEFICIARIES_LIST]: { enabled: true }, + [FeatureKey.PAYMENTS_BENEFICIARIES_ADD]: { enabled: true }, + [FeatureKey.PAYMENTS_BENEFICIARIES_DELETE]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_IMPS]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: false, reason: 'Target transfer form offers IMPS only' }, + [FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: false, reason: 'Target transfer form offers IMPS only' }, + [FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_ACCOUNT]: { enabled: true }, + [FeatureKey.PAYMENTS_TRANSFER_OWN]: { enabled: true }, + + [FeatureKey.PAYMENTS_MMID]: { + enabled: true, + description: 'The MMIDs on this profile, for receiving over IMPS', + }, + [FeatureKey.PAYMENTS_MMID_CANCEL]: { enabled: true }, + [FeatureKey.PAYMENTS_MMID_GENERATE]: { enabled: true }, + + [FeatureKey.PAYMENTS_UPI]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: false, reason: 'Not in the target screen set' }, + + // --------------------------------------------------------------- Cards ---- + [FeatureKey.CARDS]: { enabled: false, description: 'Cards tab', reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_OVERVIEW]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_DETAILS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_TRANSACTIONS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_ATM]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_APPLY_CREDIT]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: false, reason: 'Not in the target screen set' }, + + // ------------------------------------------------------------ Services ---- + [FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' }, + [FeatureKey.SERVICES_HUB]: { enabled: true }, + + [FeatureKey.SERVICES_DEPOSITS]: { enabled: true }, + [FeatureKey.SERVICES_DEPOSITS_OVERVIEW]: { enabled: true }, + [FeatureKey.SERVICES_DEPOSITS_DETAILS]: { enabled: true }, + // A customer may look at the deposits they hold, but cannot start a new + // one from the app. Both the route and every control that reaches it are + // gated on these, so switching them on is all it takes to offer it. + [FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' }, + [FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' }, + [FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true }, + + [FeatureKey.SERVICES_CHEQUE]: { enabled: true }, + [FeatureKey.SERVICES_CHEQUE_HUB]: { enabled: true }, + [FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST]: { enabled: true }, + [FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true }, + [FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true }, + [FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true }, + + [FeatureKey.SERVICES_CERTIFICATES]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: false, reason: 'Not in the target screen set' }, + + [FeatureKey.SERVICES_REQUESTS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: false, reason: 'Not in the target screen set' }, + + [FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: false, reason: 'Only entry point was the service-requests hub' }, + [FeatureKey.SERVICES_FORM_15G15H]: { enabled: false, reason: 'Only entry point was the service-requests hub' }, + [FeatureKey.SERVICES_NOMINATION]: { enabled: false, reason: 'Only entry point was the service-requests hub' }, + [FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true }, + + [FeatureKey.SERVICES_AWARENESS]: { enabled: true }, + [FeatureKey.SERVICES_AWARENESS_LIST]: { enabled: true }, + [FeatureKey.SERVICES_AWARENESS_DETAIL]: { enabled: true }, + + [FeatureKey.SERVICES_PROTECTION]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_HUB]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_SCHEME_DETAIL]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_ENROLLMENT]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_DEAF]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_DEAF_CLAIM]: { enabled: true }, + [FeatureKey.SERVICES_PROTECTION_DICGC]: { enabled: true }, + + // Not built yet — these two only raise a "coming soon" toast today, so they + // are off by default rather than advertising something that does not exist. + [FeatureKey.SERVICES_SUPPORT_CHAT]: { enabled: true }, + [FeatureKey.SERVICES_HELP_CENTER]: { enabled: true }, + + [FeatureKey.SERVICES_BALANCE_ENQUIRY]: { + enabled: false, + reason: 'No CBS endpoint returns a balance', + }, + [FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true }, + [FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true }, + // Service-desk entry points to screens the home and profile stacks already + // own. Switching one off takes the tile away, not the screen. + [FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true }, + [FeatureKey.SERVICES_MPIN_CHANGE]: { enabled: true }, + + // --------------------------------------------------------------- Loans ---- + [FeatureKey.LOANS]: { enabled: false, description: 'Loans module', reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_TYPES]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_ELIGIBILITY]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_APPLY]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_TRACKING]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_EMI_PAYMENT]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_FORECLOSURE]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_HISTORY]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.LOANS_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' }, + + // ----------------------------------------------------------- Insurance ---- + [FeatureKey.INSURANCE]: { enabled: false, description: 'Insurance module', reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_BUY]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_POLICIES]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_RENEWALS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_CLAIMS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_COMPARISON]: { enabled: false, reason: 'Not in the target screen set' }, + [FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: false, reason: 'Not in the target screen set' }, + + // ------------------------------------------------------------- Profile ---- + [FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' }, + [FeatureKey.PROFILE_MAIN]: { enabled: true }, + [FeatureKey.PROFILE_EDIT]: { enabled: true }, + [FeatureKey.PROFILE_ACCOUNTS]: { enabled: true }, + [FeatureKey.PROFILE_ACCOUNTS_ADD]: { enabled: true }, + [FeatureKey.PROFILE_BENEFICIARIES_SHORTCUT]: { enabled: true }, + [FeatureKey.PROFILE_STATEMENTS_SHORTCUT]: { enabled: true }, + + [FeatureKey.PROFILE_SETTINGS]: { enabled: true }, + [FeatureKey.PROFILE_SETTINGS_LANGUAGE]: { enabled: true }, + [FeatureKey.PROFILE_SETTINGS_NOTIFICATIONS]: { enabled: true }, + [FeatureKey.PROFILE_SETTINGS_PRIVACY]: { enabled: true }, + + [FeatureKey.PROFILE_SECURITY]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_DEVICES]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_LOGOUT_ALL]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_HIDE_BALANCE]: { enabled: true }, + + [FeatureKey.PROFILE_SUPPORT]: { enabled: true }, + [FeatureKey.PROFILE_LEGAL]: { enabled: true }, + [FeatureKey.PROFILE_ABOUT]: { enabled: true }, + [FeatureKey.PROFILE_FEATURE_FLAGS]: { + // Off by default, on purpose. Enable it through remote config for the + // tenants whose staff need it; a switchboard shipped on by default would + // be reachable by every customer. + enabled: false, + description: 'Operational feature switchboard (admin)', + }, + + // --------------------------------------------------------------- Agent ---- + [FeatureKey.AGENT]: { enabled: true, description: 'Field-agent application' }, + + [FeatureKey.AGENT_DASHBOARD]: { enabled: true }, + [FeatureKey.AGENT_DASHBOARD_MAIN]: { enabled: true }, + [FeatureKey.AGENT_DASHBOARD_ROUTE]: { enabled: true }, + + [FeatureKey.AGENT_CUSTOMERS]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_LIST]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_DETAIL]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_CREATE]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_EDIT]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_DELETE]: { enabled: true }, + [FeatureKey.AGENT_CUSTOMERS_INSTALLMENT_HISTORY]: { enabled: true }, + + [FeatureKey.AGENT_COLLECTIONS]: { enabled: true }, + [FeatureKey.AGENT_COLLECTIONS_DAILY]: { enabled: true }, + [FeatureKey.AGENT_COLLECTIONS_HISTORY]: { enabled: true }, + [FeatureKey.AGENT_COLLECTIONS_RECEIPT]: { enabled: true }, + + [FeatureKey.AGENT_LOANS]: { enabled: true }, + [FeatureKey.AGENT_LOANS_LIST]: { enabled: true }, + [FeatureKey.AGENT_LOANS_CREATE_REQUEST]: { enabled: true }, + [FeatureKey.AGENT_LOANS_ACCOUNT_OPENING]: { enabled: true }, + [FeatureKey.AGENT_LOANS_COMMISSION]: { enabled: true }, + + [FeatureKey.AGENT_PROFILE]: { enabled: true }, + [FeatureKey.AGENT_PROFILE_MAIN]: { enabled: true }, + [FeatureKey.AGENT_PROFILE_SMS_HISTORY]: { enabled: true }, + [FeatureKey.AGENT_PROFILE_CHANGE_PASSWORD]: { enabled: true }, +}; + +/** + * Build-time / environment overrides, applied on top of the defaults. + * + * Keep this keyed off `env` (or `__DEV__`) rather than editing the defaults, so + * a environment-specific switch never leaks into production config. + */ +export const ENVIRONMENT_FEATURE_OVERRIDES: FeatureOverrides = {}; diff --git a/src/features/featureOverrides.ts b/src/features/featureOverrides.ts new file mode 100644 index 0000000..3c7d6de --- /dev/null +++ b/src/features/featureOverrides.ts @@ -0,0 +1,124 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { toFeatureKey, type FeatureKey } from '../constants/featureKeys'; +import { featureService } from './featureService'; +import { FeatureSource, type FeatureOverrides } from './types'; + +const STORAGE_KEY = 'bankcbs.featureOverrides'; + +/** + * Device-local manual overrides — the `user` layer. + * + * Held in AsyncStorage rather than the Keychain: these are switches someone set + * on this handset, not a secret, and they must be readable before sign-in so the + * app renders the same way on a cold start as it did when they were set. + * + * They deliberately survive logout. Redux is wiped on sign-out (so no customer + * data leaks between sessions) but a tester's overrides are configuration, not + * session data, and losing them on every sign-out would make them useless. + * `featureRemoteConfig` handles the tenant layer, which *is* session-scoped. + * + * A kill from the bank still wins over anything set here — see + * `AUTHORITATIVE_FEATURE_SOURCES`. + */ + +/** + * Drops anything that is not a currently declared key. + * + * Necessary because stored overrides outlive the build that wrote them: a key + * that was renamed or removed in an app update would otherwise sit in the layer + * forever, matching nothing and impossible to find in the toggle UI. + */ +const narrowToKnownKeys = (raw: unknown): FeatureOverrides => { + if (typeof raw !== 'object' || raw === null) { + return {}; + } + + const result: FeatureOverrides = {}; + + for (const [candidate, value] of Object.entries(raw as Record)) { + const key = toFeatureKey(candidate); + + if (key && typeof value === 'boolean') { + result[key] = value; + } else if (__DEV__) { + console.warn(`[features] Dropping stored override "${candidate}" — not a declared key.`); + } + } + + return result; +}; + +const persist = async (overrides: FeatureOverrides) => { + try { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); + } catch (error) { + // A failed write must not take the toggle with it: the in-memory layer is + // already updated, so the switch works for this session and simply does not + // survive a restart. + if (__DEV__) { + console.warn('[features] Could not persist local overrides.', error); + } + } +}; + +export const featureOverrides = { + /** + * Loads stored overrides into the user layer. Resolves even on failure — a + * corrupt or unreadable store must fall back to the compiled-in defaults + * rather than block startup. + */ + async restore(): Promise { + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + + if (!stored) { + return {}; + } + + const overrides = narrowToKnownKeys(JSON.parse(stored)); + featureService.setSource(FeatureSource.User, overrides); + + return overrides; + } catch (error) { + if (__DEV__) { + console.warn('[features] Could not read local overrides; using defaults.', error); + } + + return {}; + } + }, + + /** Current local overrides. */ + current(): FeatureOverrides { + return featureService.getSource(FeatureSource.User); + }, + + /** Pins one feature on or off locally, and persists it. */ + async set(key: FeatureKey, value: boolean): Promise { + featureService.patchSource(FeatureSource.User, key, value); + await persist(this.current()); + }, + + /** + * Removes a local pin so the feature follows the layers below it again. + * Distinct from `set(key, false)` — that pins it off, this stops overriding. + */ + async clear(key: FeatureKey): Promise { + featureService.patchSource(FeatureSource.User, key, undefined); + await persist(this.current()); + }, + + /** Drops every local override. The "reset to defaults" action. */ + async resetAll(): Promise { + featureService.setSource(FeatureSource.User, {}); + + try { + await AsyncStorage.removeItem(STORAGE_KEY); + } catch (error) { + if (__DEV__) { + console.warn('[features] Could not clear stored overrides.', error); + } + } + }, +}; diff --git a/src/features/featureRemoteConfig.ts b/src/features/featureRemoteConfig.ts new file mode 100644 index 0000000..55da253 --- /dev/null +++ b/src/features/featureRemoteConfig.ts @@ -0,0 +1,226 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { toFeatureKey } from '../constants/featureKeys'; +import { apiClient } from '../services/api/apiClient'; +import { endpoints } from '../services/api/endpoints'; +import { featureService } from './featureService'; +import { FeatureSource, type FeatureOverrides } from './types'; +import type { FeatureConfigResult } from '../types/cbs'; + +const CACHE_KEY = 'bankcbs.featureConfig.lastKnown'; + +/** + * Server-driven feature switches — the `remote` and `tenant` layers. + * + * Two things here are load-bearing rather than incidental: + * + * **The last successful payload is cached and re-applied on launch.** Without it + * a handset that starts offline would fall back to the flags compiled into the + * bundle — which is exactly how a feature the bank killed during an incident + * comes *back on* for anyone who restarts the app on a bad connection. Caching + * the instruction is what makes the kill switch survive being offline. + * + * **Unknown keys are dropped, not trusted.** The server may legitimately + * publish keys for a newer app release; this build must ignore those rather than + * store strings that match nothing. Combined with fail-closed resolution, a + * malformed payload can only ever hide features, never reveal them. + */ + +interface CachedConfig { + version: number; + overrides: FeatureOverrides; + roleOverrides: FeatureOverrides; + fetchedAt: number; +} + +/** Narrows a server payload to declared keys, discarding anything else. */ +const narrow = (raw: Record | undefined, label: string): FeatureOverrides => { + if (!raw || typeof raw !== 'object') { + return {}; + } + + const result: FeatureOverrides = {}; + const unknown: string[] = []; + + for (const [candidate, value] of Object.entries(raw)) { + const key = toFeatureKey(candidate); + + if (key && typeof value === 'boolean') { + result[key] = value; + } else { + unknown.push(candidate); + } + } + + if (unknown.length > 0 && __DEV__) { + console.warn( + `[features] Ignored ${unknown.length} unrecognised key(s) in ${label}: ${unknown.join(', ')}`, + ); + } + + return result; +}; + +const readCache = async (): Promise => { + try { + const stored = await AsyncStorage.getItem(CACHE_KEY); + + if (!stored) { + return null; + } + + const parsed = JSON.parse(stored) as CachedConfig; + + return { + version: typeof parsed.version === 'number' ? parsed.version : 0, + // Re-narrow on read: the cache may have been written by an older build + // whose key set has since changed. + overrides: narrow(parsed.overrides as Record, 'cached config'), + roleOverrides: narrow(parsed.roleOverrides as Record, 'cached role config'), + fetchedAt: typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : 0, + }; + } catch { + return null; + } +}; + +const writeCache = async (config: CachedConfig) => { + try { + await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(config)); + } catch (error) { + if (__DEV__) { + console.warn('[features] Could not cache feature config.', error); + } + } +}; + +/** + * Fixture used while `useMockServer` is on, since `/features/config` has no + * backend behind it yet. + * + * Deliberately empty. A fixture that switched features off would make mock mode + * differ from a fresh install for reasons nobody could find — to exercise a kill + * switch locally, use the dev toggle screen, or add an entry here temporarily: + * + * overrides: { 'loans.apply': false } + */ +const MOCK_CONFIG: FeatureConfigResult = { + version: 0, + overrides: {}, +}; + +const fetchConfig = async (): Promise => { + const response = await apiClient.get(endpoints.features.config); + + return response.data; +}; + +export const featureRemoteConfig = { + /** + * Applies the cached configuration, if any. Call before the first fetch so a + * cold start on a dead connection still honours the bank's last instruction + * instead of reverting to bundle defaults. + */ + async applyCached(): Promise { + const cached = await readCache(); + + if (!cached) { + return false; + } + + featureService.setSource(FeatureSource.Remote, cached.overrides); + featureService.setSource(FeatureSource.Tenant, cached.roleOverrides); + + return true; + }, + + /** + * Fetches the live configuration and applies it to the remote and tenant + * layers. + * + * Returns `false` when nothing could be applied, having left the existing + * layers untouched — a failed refresh must never widen access, so the last + * known configuration stays in force rather than falling back to defaults. + * + * Safe to call unauthenticated: the response then carries no `roleOverrides` + * and the tenant layer is left as it is. + */ + async refresh({ useMock }: { useMock: boolean }): Promise { + try { + const result = useMock ? MOCK_CONFIG : await fetchConfig(); + + const overrides = narrow(result.overrides, 'feature config'); + const hasRoleScope = result.roleOverrides !== undefined; + const roleOverrides = narrow(result.roleOverrides, 'role feature config'); + + featureService.setSource(FeatureSource.Remote, overrides); + + // Only touch the tenant layer when the response actually carried a role + // scope. An unauthenticated fetch omits it, and clearing the layer then + // would drop role rules that a previous authenticated fetch had applied. + if (hasRoleScope) { + featureService.setSource(FeatureSource.Tenant, roleOverrides); + } + + await writeCache({ + version: result.version ?? 0, + overrides, + roleOverrides: hasRoleScope + ? roleOverrides + : featureService.getSource(FeatureSource.Tenant), + fetchedAt: Date.now(), + }); + + if (__DEV__) { + const killed = Object.entries(overrides).filter(([, on]) => !on).length; + console.log( + `[features] Applied remote config v${result.version ?? 0} — ` + + `${Object.keys(overrides).length} override(s), ${killed} disabled.`, + ); + } + + return true; + } catch (error) { + if (__DEV__) { + console.warn('[features] Feature config refresh failed; keeping current config.', error); + } + + return false; + } + }, + + /** + * Clears the role-scoped layer on sign-out. The org-wide layer stays: it + * applies to the sign-in screen too, and it is not session data. + */ + clearRoleScope() { + featureService.setSource(FeatureSource.Tenant, {}); + }, + + /** + * Forgets the signed-out customer's entitlements for good, keeping the bank's + * org-wide instruction. + * + * `clearRoleScope` only drops the in-memory layer. The cache on disk still + * held `roleOverrides`, and `applyCached` re-applies both layers on the next + * cold start — so one customer's role scope came back and sat over whoever + * launched the app next. That is the leak this closes. + * + * The org-wide half is rewritten rather than deleted, deliberately. It is the + * bank's kill switch, and it is cached precisely so a handset that starts + * offline still honours a feature the bank killed during an incident instead + * of reverting to whatever the bundle was built with. Dropping the file would + * turn a sign-out into a way to switch those features back on. + */ + async forgetRoleScope() { + featureService.setSource(FeatureSource.Tenant, {}); + + const cached = await readCache(); + + if (!cached || Object.keys(cached.roleOverrides).length === 0) { + return; + } + + await writeCache({ ...cached, roleOverrides: {} }); + }, +}; diff --git a/src/features/featureService.test.ts b/src/features/featureService.test.ts new file mode 100644 index 0000000..ff50255 --- /dev/null +++ b/src/features/featureService.test.ts @@ -0,0 +1,262 @@ +import { FeatureKey, getFeatureAncestors, getParentFeatureKey, toFeatureKey } from '../constants/featureKeys'; +import { + featureService, + filterEnabled, + guardFeature, + isFeatureEnabled, + isFeatureKeyEnabled, +} from './featureService'; +import { FeatureSource } from './types'; + +describe('feature keys', () => { + it('derives the parent from the dot path', () => { + expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe( + FeatureKey.SERVICES_CHEQUE, + ); + expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE)).toBe(FeatureKey.SERVICES); + }); + + it('returns undefined for a root key', () => { + expect(getParentFeatureKey(FeatureKey.SERVICES)).toBeUndefined(); + }); + + it('lists ancestors nearest-first', () => { + expect(getFeatureAncestors(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toEqual([ + FeatureKey.SERVICES_CHEQUE, + FeatureKey.SERVICES, + ]); + }); + + it('narrows only strings that are declared keys', () => { + expect(toFeatureKey('services.cheque')).toBe(FeatureKey.SERVICES_CHEQUE); + expect(toFeatureKey('services.nope')).toBeUndefined(); + expect(toFeatureKey(undefined)).toBeUndefined(); + expect(toFeatureKey('')).toBeUndefined(); + }); +}); + +describe('featureService', () => { + afterEach(() => { + featureService.resetSources(); + }); + + it('has a valid static configuration', () => { + expect(featureService.validate()).toEqual([]); + }); + + it('enables a feature whose config and ancestors are all on', () => { + expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(true); + }); + + it('disables every descendant when a parent is turned off', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false }); + + expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE)).toBe(false); + expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_HUB)).toBe(false); + expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false); + // Siblings under a different parent are untouched. + expect(isFeatureEnabled(FeatureKey.SERVICES_PROTECTION_DEAF)).toBe(true); + }); + + it('cannot re-enable a child while its parent is off', () => { + featureService.setSource(FeatureSource.Remote, { + [FeatureKey.SERVICES_CHEQUE]: false, + [FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: true, + }); + + expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false); + }); + + it('disables a single child without touching its parent or siblings', () => { + featureService.setSource(FeatureSource.Remote, { + [FeatureKey.AGENT_CUSTOMERS_CREATE]: false, + [FeatureKey.AGENT_CUSTOMERS_DELETE]: false, + }); + + expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS)).toBe(true); + expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_LIST)).toBe(true); + expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_EDIT)).toBe(true); + expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_CREATE)).toBe(false); + expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_DELETE)).toBe(false); + }); + + it('fails closed on an unknown key', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(isFeatureEnabled('totally.made.up' as FeatureKey)).toBe(false); + expect(isFeatureKeyEnabled('totally.made.up')).toBe(false); + expect(isFeatureKeyEnabled(undefined)).toBe(false); + + warn.mockRestore(); + }); + + describe('source priority', () => { + it('lets a higher-priority source win', () => { + // Nothing authoritative has an opinion here, so ordinary precedence + // applies and the user layer — the highest — decides. + featureService.setSource(FeatureSource.Environment, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false); + + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true }); + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true); + }); + + // The kill switch. A device-local override must not be able to re-enable + // something the bank has shut down, even though the user layer outranks the + // remote one for every other purpose. + it('does not let a local override lift a remote kill', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true }); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false); + expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toMatchObject({ + enabled: false, + killedBy: FeatureSource.Remote, + locallyToggleable: false, + }); + }); + + it('does not let a local override lift a tenant kill', () => { + featureService.setSource(FeatureSource.Tenant, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true }); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false); + }); + + // The asymmetry is deliberate: switching a feature off locally is always + // safe, so an authoritative `true` stays a preference rather than a mandate. + it('still lets a local override switch off a remotely enabled feature', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true }); + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false); + expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS).locallyToggleable).toBe(true); + }); + + it('reports a feature held down by a disabled ancestor as not locally toggleable', () => { + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER]: false }); + + const resolution = featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS); + + expect(resolution.enabled).toBe(false); + // Its own flag is untouched — it is the parent that is off, so flipping + // this row would achieve nothing and the UI must say so. + expect(resolution.ownFlag).toBe(true); + expect(resolution.blockedBy).toBe(FeatureKey.PAYMENTS_TRANSFER); + expect(resolution.locallyToggleable).toBe(false); + }); + + it('patches a single key without discarding the rest of the layer', () => { + featureService.setSource(FeatureSource.User, { + [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false, + [FeatureKey.NOTIFICATIONS]: false, + }); + + featureService.patchSource(FeatureSource.User, FeatureKey.PAYMENTS_TRANSFER_IMPS, undefined); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true); + // The toggle screens patch one row per tap; losing the others would make + // every switch reset the whole layer. + expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS)).toBe(false); + }); + + it('falls back to the next source down when a layer is cleared', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true }); + + featureService.clearSource(FeatureSource.User); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false); + }); + + it('returns to the compiled defaults after reset', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false }); + featureService.resetSources(); + + expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true); + }); + + it('refuses to replace the default layer', () => { + expect(() => featureService.setSource(FeatureSource.Default, {})).toThrow(); + }); + }); + + it('notifies subscribers when config changes', () => { + const listener = jest.fn(); + const unsubscribe = featureService.subscribe(listener); + + featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false }); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: true }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('invalidates cached results when a source changes', () => { + expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(true); + + featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false }); + + expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(false); + }); +}); + +describe('guardFeature', () => { + afterEach(() => { + featureService.resetSources(); + }); + + it('runs the action while the feature is enabled', () => { + const action = jest.fn(() => 'ran'); + const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action); + + expect(guarded()).toBe('ran'); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('does not run the action — or fire its API call — when disabled', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const action = jest.fn(); + const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action); + + featureService.setSource(FeatureSource.Remote, { + [FeatureKey.AGENT_CUSTOMERS_DELETE]: false, + }); + + expect(guarded()).toBeUndefined(); + expect(action).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); +}); + +describe('filterEnabled', () => { + afterEach(() => { + featureService.resetSources(); + }); + + const items = [ + { id: 'protection', feature: FeatureKey.SERVICES_PROTECTION_HUB }, + { id: 'cheque', feature: FeatureKey.SERVICES_CHEQUE_HUB }, + { id: 'untagged' }, + ]; + + it('keeps enabled and untagged items', () => { + expect(filterEnabled(items).map((item) => item.id)).toEqual([ + 'protection', + 'cheque', + 'untagged', + ]); + }); + + it('drops items whose feature is disabled', () => { + featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false }); + + expect(filterEnabled(items).map((item) => item.id)).toEqual(['protection', 'untagged']); + }); + + it('drops items whose feature string is not a declared key', () => { + expect(filterEnabled([{ id: 'bogus', feature: 'not.a.key' }])).toEqual([]); + }); +}); diff --git a/src/features/featureService.ts b/src/features/featureService.ts new file mode 100644 index 0000000..84e3121 --- /dev/null +++ b/src/features/featureService.ts @@ -0,0 +1,415 @@ +import { + ALL_FEATURE_KEYS, + FeatureKey, + getFeatureAncestors, + getParentFeatureKey, + toFeatureKey, +} from '../constants/featureKeys'; +import { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig'; +import { + AUTHORITATIVE_FEATURE_SOURCES, + FEATURE_SOURCE_PRIORITY, + FeatureSource, + type FeatureConfig, + type FeatureConfigIssue, + type FeatureOverrides, + type FeatureResolution, +} from './types'; + +/** + * Central feature-flag engine. + * + * Single instance for the whole app (exported as `featureService`). It owns: + * - the registry built from the static config, + * - the stack of override layers (environment → remote → tenant → user), + * - a resolution cache, invalidated whenever any layer changes, + * - a listener set so React can re-render on remote-config updates. + * + * Resolution is *fail closed*: an unknown key, or a key whose config is missing, + * resolves to `false`. In a banking app an unrecognised flag must hide the + * feature, never reveal it. + */ +class FeatureService { + private readonly registry = new Map(); + + private readonly layers = new Map(); + + private readonly cache = new Map(); + + private readonly listeners = new Set<() => void>(); + + /** Bumped on every config change; used as the `useSyncExternalStore` snapshot. */ + private version = 0; + + constructor() { + this.buildRegistry(); + this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES }); + } + + // ------------------------------------------------------------- registry --- + + private buildRegistry() { + this.registry.clear(); + + for (const key of ALL_FEATURE_KEYS) { + const definition = DEFAULT_FEATURE_CONFIG[key]; + + this.registry.set(key, { + key, + enabled: definition.enabled, + parent: getParentFeatureKey(key), + }); + } + } + + /** + * Verifies the configuration is internally consistent. Every declared key must + * have config, and every non-root key must have a declared parent (otherwise a + * typo like `loans.aply.submit` would silently become a root feature that no + * parent can ever switch off). + * + * Called from `FeatureProvider` in development. + */ + validate(): FeatureConfigIssue[] { + const issues: FeatureConfigIssue[] = []; + + for (const key of ALL_FEATURE_KEYS) { + if (!(key in DEFAULT_FEATURE_CONFIG)) { + issues.push({ + key, + problem: 'missing-config', + detail: `"${key}" is declared in FeatureKey but absent from DEFAULT_FEATURE_CONFIG.`, + }); + } + + const lastDot = key.lastIndexOf('.'); + + if (lastDot !== -1 && !toFeatureKey(key.slice(0, lastDot))) { + issues.push({ + key, + problem: 'missing-parent', + detail: `"${key}" implies a parent "${key.slice( + 0, + lastDot, + )}" which is not a declared FeatureKey.`, + }); + } + } + + for (const configured of Object.keys(DEFAULT_FEATURE_CONFIG)) { + if (!toFeatureKey(configured)) { + issues.push({ + key: configured, + problem: 'unknown-key', + detail: `DEFAULT_FEATURE_CONFIG has "${configured}" which is not a FeatureKey.`, + }); + } + } + + return issues; + } + + // ----------------------------------------------------------- resolution --- + + /** + * The feature's own flag, before considering ancestors: the value from the + * highest-priority source that defines it, falling back to the static config. + */ + private resolveOwnFlag(key: FeatureKey): boolean { + return this.resolveOwnFlagWithSource(key).value; + } + + /** + * The own flag plus the layer that decided it. Two phases, and the order + * between them is the security-relevant part: + * + * 1. A kill from an authoritative layer wins outright, whatever sits above it + * in the priority list. A device-local toggle must not be able to re-enable + * a feature the bank has shut down. + * 2. Otherwise the highest-priority layer that defines the key wins, falling + * back to the static config compiled into the bundle. + */ + private resolveOwnFlagWithSource(key: FeatureKey): { + value: boolean; + source: FeatureSource; + killedBy?: FeatureSource; + } { + for (const source of AUTHORITATIVE_FEATURE_SOURCES) { + if (this.layers.get(source)?.[key] === false) { + return { value: false, source, killedBy: source }; + } + } + + for (let i = FEATURE_SOURCE_PRIORITY.length - 1; i >= 0; i -= 1) { + const source = FEATURE_SOURCE_PRIORITY[i]; + + if (!source || source === FeatureSource.Default) { + continue; + } + + const override = this.layers.get(source)?.[key]; + + if (typeof override === 'boolean') { + return { value: override, source }; + } + } + + return { + value: this.registry.get(key)?.enabled ?? false, + source: FeatureSource.Default, + }; + } + + /** + * Whether a feature is usable: it must exist, its own flag must be on, and + * every ancestor must also resolve to enabled. + */ + isEnabled(key: FeatureKey): boolean { + const cached = this.cache.get(key); + + if (typeof cached === 'boolean') { + return cached; + } + + let enabled: boolean; + + if (!this.registry.has(key)) { + if (__DEV__) { + console.warn( + `[features] Unknown feature key "${key}". Resolving to disabled. ` + + 'Add it to FeatureKey and DEFAULT_FEATURE_CONFIG.', + ); + } + enabled = false; + } else if (!this.resolveOwnFlag(key)) { + enabled = false; + } else { + // Walking ancestors nearest-first lets the recursive call hit the cache. + enabled = getFeatureAncestors(key).every((ancestor) => this.isEnabled(ancestor)); + } + + this.cache.set(key, enabled); + + return enabled; + } + + /** True only when every supplied key is enabled. */ + areAllEnabled(keys: readonly FeatureKey[]): boolean { + return keys.every((key) => this.isEnabled(key)); + } + + /** True when at least one supplied key is enabled. */ + isAnyEnabled(keys: readonly FeatureKey[]): boolean { + return keys.some((key) => this.isEnabled(key)); + } + + /** + * Resolves an untrusted string (JSON UI, deep link, remote payload) and + * reports whether it maps to an enabled feature. Unknown strings → false. + */ + isEnabledKey(value: string | null | undefined): boolean { + const key = toFeatureKey(value); + + return key ? this.isEnabled(key) : false; + } + + // -------------------------------------------------------------- sources --- + + /** + * Replaces one layer's overrides wholesale and notifies subscribers. + * This is the seam for Firebase Remote Config, an API-driven config service, + * or per-tenant/role rules — no UI component changes when a layer is added. + */ + setSource(source: FeatureSource, overrides: FeatureOverrides) { + if (source === FeatureSource.Default) { + throw new Error('[features] The default layer is static and cannot be replaced.'); + } + + this.layers.set(source, { ...overrides }); + this.invalidate(); + } + + /** + * Sets or clears a *single* key within one layer, leaving the rest of that + * layer alone. This is what the toggle screens use: `setSource` would discard + * every other override in the layer on each tap. + * + * Passing `undefined` removes the key, so the feature falls back to whatever + * the layers below it say rather than being pinned to a value. + */ + patchSource(source: FeatureSource, key: FeatureKey, value: boolean | undefined) { + if (source === FeatureSource.Default) { + throw new Error('[features] The default layer is static and cannot be patched.'); + } + + const next: FeatureOverrides = { ...this.layers.get(source) }; + + if (value === undefined) { + delete next[key]; + } else { + next[key] = value; + } + + this.layers.set(source, next); + this.invalidate(); + } + + /** One layer's overrides, for the toggle screens and for persisting them. */ + getSource(source: FeatureSource): FeatureOverrides { + return { ...this.layers.get(source) }; + } + + /** Drops a layer entirely (e.g. on logout, clear the user/tenant layers). */ + clearSource(source: FeatureSource) { + if (this.layers.delete(source)) { + this.invalidate(); + } + } + + /** Removes every non-default layer. Used on logout and in tests. */ + resetSources() { + this.layers.clear(); + this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES }); + this.invalidate(); + } + + private invalidate() { + this.cache.clear(); + this.version += 1; + this.listeners.forEach((listener) => listener()); + } + + // ------------------------------------------------------------ reactivity --- + + /** Subscribes to config changes. Returns an unsubscribe function. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + }; + + /** Monotonic version, stable between changes — safe for useSyncExternalStore. */ + getVersion = (): number => this.version; + + // ----------------------------------------------------------- diagnostics --- + + /** Full resolved state. Development/debugging aid only. */ + getSnapshot(): Record { + const snapshot = {} as Record; + + for (const key of ALL_FEATURE_KEYS) { + snapshot[key] = this.isEnabled(key); + } + + return snapshot; + } + + /** Resolved config for one feature, including where its parent sits. */ + getConfig(key: FeatureKey): FeatureConfig | undefined { + return this.registry.get(key); + } + + /** + * Why one feature resolved the way it did. + * + * The toggle screens need this rather than a bare boolean: a row that is off + * because the bank killed it, and a row that is off because its parent is off, + * both look identical as a `false` — and neither can be fixed by flipping that + * row. Showing the reason is what stops someone tapping a locked toggle and + * concluding the app is broken. + */ + explain(key: FeatureKey): FeatureResolution { + const { value: ownFlag, source, killedBy } = this.resolveOwnFlagWithSource(key); + const blockedBy = ownFlag + ? getFeatureAncestors(key).find((ancestor) => !this.isEnabled(ancestor)) + : undefined; + + return { + key, + enabled: this.isEnabled(key), + ownFlag, + decidedBy: source, + ...(killedBy ? { killedBy } : {}), + ...(blockedBy ? { blockedBy } : {}), + // A killed feature cannot be lifted locally, and neither can one whose + // parent is off — the ancestor has to be fixed first. + locallyToggleable: !killedBy && !blockedBy, + }; + } +} + +export const featureService = new FeatureService(); + +/** + * Canonical feature check used across the app. + * + * Automatically accounts for the feature's own flag, every ancestor, whether the + * key exists at all, and the active source layers. + * + * if (isFeatureEnabled(FeatureKey.PAYMENTS_BENEFICIARIES_ADD)) { … } + */ +export const isFeatureEnabled = (key: FeatureKey): boolean => featureService.isEnabled(key); + +/** Every key enabled? */ +export const areAllFeaturesEnabled = (keys: readonly FeatureKey[]): boolean => + featureService.areAllEnabled(keys); + +/** Any key enabled? */ +export const isAnyFeatureEnabled = (keys: readonly FeatureKey[]): boolean => + featureService.isAnyEnabled(keys); + +/** + * Check for a feature reference that arrived as a plain string — JSON-driven UI, + * deep links, remote payloads. Unknown strings resolve to `false`. + */ +export const isFeatureKeyEnabled = (value: string | null | undefined): boolean => + featureService.isEnabledKey(value); + +/** + * Wraps an action so it cannot run while its feature is disabled. Use for any + * handler that mutates state or calls an API, so a disabled feature can never + * fire a request even if some UI path reaches it. + * + * const deleteUser = guardFeature(FeatureKey.USERS_DELETE, async (id) => { … }); + */ +export function guardFeature( + key: FeatureKey, + action: (...args: TArgs) => TResult, +): (...args: TArgs) => TResult | undefined { + return (...args: TArgs) => { + if (!isFeatureEnabled(key)) { + if (__DEV__) { + console.warn(`[features] Blocked action for disabled feature "${key}".`); + } + + return undefined; + } + + return action(...args); + }; +} + +/** + * Throwing variant, for service/thunk layers where silently doing nothing would + * hide a bug. Prefer `guardFeature` in UI handlers. + */ +export function assertFeatureEnabled(key: FeatureKey): void { + if (!isFeatureEnabled(key)) { + throw new Error(`[features] Feature "${key}" is disabled.`); + } +} + +/** + * Filters any list of feature-tagged items down to the enabled ones. This is the + * single helper behind gated service tiles, menu rows, search entries and + * JSON-driven component arrays. + * + * Items without a `feature` field are always kept — tagging is opt-in. + */ +export function filterEnabled( + items: readonly T[], +): T[] { + return items.filter((item) => item.feature === undefined || isFeatureKeyEnabled(item.feature)); +} diff --git a/src/features/hydrateFeatures.ts b/src/features/hydrateFeatures.ts new file mode 100644 index 0000000..057dde4 --- /dev/null +++ b/src/features/hydrateFeatures.ts @@ -0,0 +1,29 @@ +import { env } from '../config/env'; +import { featureOverrides } from './featureOverrides'; +import { featureRemoteConfig } from './featureRemoteConfig'; + +/** + * Brings every feature layer up before the app renders. Passed to + * `FeatureProvider`'s `hydrate` slot, which holds `ready` until it resolves. + * + * The order matters: + * + * 1. **Local overrides** — read from storage first. Cheap, and it means a + * tester's switches are in force even if the network never answers. + * 2. **Cached server config** — the bank's last known instruction. Applied + * before the fetch so a cold start offline still honours a kill switch + * rather than reverting to whatever the bundle was built with. + * 3. **Live fetch** — replaces the cache when it succeeds, and is a no-op when + * it does not, leaving step 2 in force. + * + * The awaits are sequential on purpose. Running them together would let the + * fetch land before the cache and then be overwritten by it. + * + * This resolves the org-wide scope only; role-scoped switches need a session and + * are refreshed at sign-in (see `useAppBootstrap`). + */ +export const hydrateFeatures = async (): Promise => { + await featureOverrides.restore(); + await featureRemoteConfig.applyCached(); + await featureRemoteConfig.refresh({ useMock: env.useMockServer }); +}; diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..54adeae --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1,45 @@ +/** + * Centralised feature management. + * + * Import everything feature-related from here: + * + * import { FeatureGate, useFeature, isFeatureEnabled } from '../../features'; + * import { FeatureKey } from '../../constants/featureKeys'; + */ +export { FeatureGate, DynamicFeatureGate } from './FeatureGate'; +export { FeatureProvider, useFeatureContext } from './FeatureProvider'; +export { + areAllFeaturesEnabled, + assertFeatureEnabled, + featureService, + filterEnabled, + guardFeature, + isAnyFeatureEnabled, + isFeatureEnabled, + isFeatureKeyEnabled, +} from './featureService'; +export { + useAllFeatures, + useAnyFeature, + useEnabledItems, + useFeature, + useFeatureAction, + useFeatures, + useFeatureVersion, +} from './useFeature'; +export { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig'; +export { featureOverrides } from './featureOverrides'; +export { featureRemoteConfig } from './featureRemoteConfig'; +export { hydrateFeatures } from './hydrateFeatures'; +export { ADMIN_TOGGLEABLE_FEATURES } from './adminFeatures'; +export { + AUTHORITATIVE_FEATURE_SOURCES, + FEATURE_SOURCE_PRIORITY, + FeatureSource, + type FeatureConfig, + type FeatureConfigIssue, + type FeatureDefinition, + type FeatureOverrides, + type FeatureResolution, + type FeatureTagged, +} from './types'; diff --git a/src/features/types.ts b/src/features/types.ts new file mode 100644 index 0000000..49efa38 --- /dev/null +++ b/src/features/types.ts @@ -0,0 +1,117 @@ +import type { FeatureKey } from '../constants/featureKeys'; + +/** + * How a single feature is declared in the static configuration. + */ +export type FeatureDefinition = { + /** When false the feature and everything beneath it is hidden. */ + enabled: boolean; + /** Why this feature exists / what it gates. Documentation only. */ + description?: string; + /** + * Why this feature ships off. Documentation only, and only meaningful + * alongside `enabled: false` — it is the note a reviewer needs to tell a + * deliberate scope decision from an accidental one. + */ + reason?: string; +}; + +/** + * A fully resolved feature entry as held by the registry. `parent` is derived + * from the key's dot path rather than hand-written, so the hierarchy can never + * drift from the key naming. + */ +export type FeatureConfig = { + key: FeatureKey; + enabled: boolean; + parent?: FeatureKey; +}; + +/** + * Configuration layers, lowest priority first. A value supplied by a later + * source overrides the same key from an earlier one: + * + * default → environment → remote → tenant → user + * + * `default` is the static config compiled into the bundle. The remaining layers + * are empty until something registers them, which is what lets us add Firebase + * Remote Config, an API-driven config or per-tenant/role rules later without + * touching a single UI component. + */ +export enum FeatureSource { + Default = 'default', + Environment = 'environment', + Remote = 'remote', + Tenant = 'tenant', + User = 'user', +} + +/** Source precedence, lowest → highest. */ +export const FEATURE_SOURCE_PRIORITY: readonly FeatureSource[] = [ + FeatureSource.Default, + FeatureSource.Environment, + FeatureSource.Remote, + FeatureSource.Tenant, + FeatureSource.User, +]; + +/** + * Layers whose `false` is a **kill switch**: once one of these turns a feature + * off, no higher-priority layer may turn it back on. + * + * Priority alone is the wrong tool here. `user` sits above `remote` in the list + * above — deliberately, so a local override can enable something for testing — + * but that same ordering would let a device-local toggle re-enable a feature the + * bank had shut down for a live incident. So a kill is resolved *before* + * precedence, and precedence then decides only among features nobody has killed. + * + * Note the asymmetry: an authoritative `true` is only a preference and a local + * layer may still switch it off. Turning a feature *off* locally is always safe; + * turning one *on* against the bank's instruction is not. + */ +export const AUTHORITATIVE_FEATURE_SOURCES: readonly FeatureSource[] = [ + FeatureSource.Remote, + FeatureSource.Tenant, +]; + +/** + * Why one feature resolved the way it did. Drives the toggle screens, which + * must show *why* a row is off — a key held down by a remote kill or by a + * disabled parent cannot be fixed by flipping the row itself. + */ +export type FeatureResolution = { + key: FeatureKey; + /** Final answer, ancestors included. This is what `isEnabled` returns. */ + enabled: boolean; + /** The feature's own flag, before ancestors are considered. */ + ownFlag: boolean; + /** Layer that decided the own flag. */ + decidedBy: FeatureSource; + /** Set when an authoritative layer forced it off. No local override lifts this. */ + killedBy?: FeatureSource; + /** Nearest ancestor that is off, when the own flag is on but the feature is not. */ + blockedBy?: FeatureKey; + /** False when a local toggle cannot change the outcome (killed, or parent off). */ + locallyToggleable: boolean; +}; + +/** A sparse set of overrides contributed by one source. */ +export type FeatureOverrides = Partial>; + +/** + * Anything carrying an optional feature reference — used by JSON-driven UI and + * by static option arrays (service tiles, menu rows, search entries). + * + * The field is a plain string so it can come straight from JSON; it is narrowed + * through `toFeatureKey` before use, and an unknown value hides the item. + */ +export type FeatureTagged = { + feature?: FeatureKey | string; +}; + +/** Result of validating the configuration against the key enum. */ +export type FeatureConfigIssue = { + key: string; + problem: 'missing-config' | 'unknown-key' | 'missing-parent'; + detail: string; +}; diff --git a/src/features/useFeature.ts b/src/features/useFeature.ts new file mode 100644 index 0000000..63b76bc --- /dev/null +++ b/src/features/useFeature.ts @@ -0,0 +1,130 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import type { FeatureKey } from '../constants/featureKeys'; +import { featureService, isFeatureEnabled } from './featureService'; + +/** + * Subscribes the calling component to feature-config changes. + * + * The store snapshot is the service's version counter, which only changes when a + * source layer is replaced. That keeps `useSyncExternalStore` stable (no render + * loops) while still re-rendering everything the moment remote config lands. + */ +export const useFeatureVersion = (): number => + useSyncExternalStore( + featureService.subscribe, + featureService.getVersion, + featureService.getVersion, + ); + +/** + * Whether a single feature is enabled, accounting for its ancestors. + * + * const canAddBeneficiary = useFeature(FeatureKey.PAYMENTS_BENEFICIARIES_ADD); + * return <>{canAddBeneficiary && }; + */ +export const useFeature = (key: FeatureKey): boolean => { + const version = useFeatureVersion(); + + // `version` looks unused to the linter, but `isFeatureEnabled` reads mutable + // service state — the version is exactly what tells us that state changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => isFeatureEnabled(key), [key, version]); +}; + +/** + * Resolves several features at once, keyed by feature key. Cheaper and tidier + * than calling `useFeature` repeatedly in a screen with many gated controls. + * + * const flags = useFeatures([FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_CONTROLS_LIMITS]); + * flags[FeatureKey.CARDS_CONTROLS_FREEZE] // boolean + */ +export const useFeatures = ( + keys: T, +): Record => { + const version = useFeatureVersion(); + // Key the memo on the contents, so an inline array literal does not thrash it. + const signature = keys.join('|'); + + return useMemo(() => { + const result = {} as Record; + + for (const key of keys) { + result[key as T[number]] = isFeatureEnabled(key); + } + + return result; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature, version]); +}; + +/** True only when every listed feature is enabled. */ +export const useAllFeatures = (keys: readonly FeatureKey[]): boolean => { + const version = useFeatureVersion(); + const signature = keys.join('|'); + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => keys.every((key) => isFeatureEnabled(key)), [signature, version]); +}; + +/** True when at least one listed feature is enabled. */ +export const useAnyFeature = (keys: readonly FeatureKey[]): boolean => { + const version = useFeatureVersion(); + const signature = keys.join('|'); + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => keys.some((key) => isFeatureEnabled(key)), [signature, version]); +}; + +/** + * Wraps a handler so it is inert while its feature is disabled — the callback + * still exists (keeping call sites simple) but does nothing and fires no API + * call. Pair with `FeatureGate` so the control is not rendered either. + * + * const onDelete = useFeatureAction(FeatureKey.PAYMENTS_BENEFICIARIES_DELETE, () => + * dispatch(deleteBeneficiary(id)), + * ); + */ +export const useFeatureAction = ( + key: FeatureKey, + action: (...args: TArgs) => void, +): ((...args: TArgs) => void) => { + const enabled = useFeature(key); + + return useCallback( + (...args: TArgs) => { + if (!enabled) { + if (__DEV__) { + console.warn(`[features] Blocked action for disabled feature "${key}".`); + } + + return; + } + + action(...args); + }, + [enabled, key, action], + ); +}; + +/** + * Filters a list of feature-tagged items down to the enabled ones, recomputing + * when config changes. Use for service grids, menus and JSON-driven UI arrays. + */ +export const useEnabledItems = ( + items: readonly T[], +): T[] => { + const version = useFeatureVersion(); + + return useMemo( + () => + items.filter( + (item) => + item.feature === undefined || + (typeof item.feature === 'string' && featureService.isEnabledKey(item.feature)), + ), + // `version` is the signal that the service's mutable state changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + [items, version], + ); +}; diff --git a/src/hooks/useApiErrorToast.ts b/src/hooks/useApiErrorToast.ts new file mode 100644 index 0000000..6971692 --- /dev/null +++ b/src/hooks/useApiErrorToast.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { describeError, isClientErrorCode } from '../services/api/cbsErrors'; +import type { ApiError } from '../types/api'; +import { useToast } from './useToast'; + +/** + * Surfaces a backend failure as a toast. The title always comes from the app's + * own copy for the error code — stable and translatable. + * + * The body prefers the server's message, which is usually more specific than + * anything the client could say about that particular failure. The exception is + * a failure the client invented because nothing answered: there the attached + * message is a transport detail like axios's bare "Network Error", which is + * worse than useless in front of a customer, so our own copy wins. + */ +export const useApiErrorToast = () => { + const toast = useToast(); + + return useCallback( + (error?: ApiError | null) => { + if (!error) { + return; + } + + const copy = describeError(error.code); + const body = isClientErrorCode(error.code) + ? copy.description + : error.message || copy.description; + + toast(copy.title, 'error', body); + }, + [toast], + ); +}; diff --git a/src/hooks/useAppBootstrap.ts b/src/hooks/useAppBootstrap.ts index 269a6fa..d90abd7 100644 --- a/src/hooks/useAppBootstrap.ts +++ b/src/hooks/useAppBootstrap.ts @@ -1,7 +1,11 @@ import { useEffect, useRef } from 'react'; import { useAppDispatch, useAppSelector } from '../app/hooks'; -import { bootstrapAuthSession, logoutSession } from '../modules/auth/store/authThunk'; +import { + bootstrapAuthSession, + loadSignInContext, + logoutSession, +} from '../modules/auth/store/authThunk'; import { selectIsAuthenticated } from '../modules/auth/store/authSelectors'; import { seedDashboardState } from '../modules/home/store/accountThunk'; import { seedBeneficiaries } from '../modules/transfer/store/transferThunk'; @@ -16,12 +20,19 @@ import { seedInsurancePlans } from '../modules/insurance/store/insuranceThunk'; import { seedCards } from '../modules/cards/store/cardsThunk'; import { seedUserProfile } from '../modules/profile/store/userThunk'; import { markAppReady } from '../modules/ui/store/uiThunk'; +import { env } from '../config/env'; +import { featureRemoteConfig } from '../features'; import { registerUnauthorizedHandler } from '../services/api/interceptors'; import { registerForPushNotifications } from '../services/notifications/pushNotifications'; import { useSessionTimeout } from './useSessionTimeout'; import { useToast } from './useToast'; -/** Dispatch all seed thunks to populate demo/mock data. */ +/** + * Populates the modules that have no CBS backend behind them yet — the + * dashboard, cards, loans, insurance, deposits, service requests and the agent + * app. Registration, sign-in, accounts/MMID and payees are live and are not + * seeded from here. + */ const seedAllData = (dispatch: ReturnType) => { dispatch(seedUserProfile()); dispatch(seedDashboardState()); @@ -39,33 +50,55 @@ export const useAppBootstrap = () => { const dispatch = useAppDispatch(); const toast = useToast(); const isAuthenticated = useAppSelector(selectIsAuthenticated); - const hasBootstrapped = useRef(false); + const wasAuthenticated = useRef(false); useSessionTimeout(); - // Initial bootstrap on mount + // Initial bootstrap on mount. `bootstrapAuthSession` also mints the device id + // and validates any stored session against the server before trusting it. useEffect(() => { - dispatch(bootstrapAuthSession()); + void dispatch(bootstrapAuthSession()); seedAllData(dispatch); dispatch(markAppReady()); void registerForPushNotifications(); - hasBootstrapped.current = true; }, [dispatch]); - // Re-seed all data when the user logs back in after a logout. - // Logout wipes all Redux state via rootReducer, so profile/dashboard/etc. - // need to be re-populated when isAuthenticated flips back to true. useEffect(() => { - if (isAuthenticated && hasBootstrapped.current) { + if (isAuthenticated && !wasAuthenticated.current) { + // Logout wipes all Redux state, so profile/dashboard/etc. need + // re-populating when the user signs back in. seedAllData(dispatch); + + // The startup fetch ran unauthenticated, so it could only carry the + // org-wide scope. Now that there is a session the server can also tell us + // what this customer's role and tenant are allowed to see. + void featureRemoteConfig.refresh({ useMock: env.useMockServer }); } + + if (!isAuthenticated && wasAuthenticated.current) { + // Role-scoped switches belong to the session that is ending. Leaving them + // in force would apply one customer's entitlements to whoever signs in + // next on this handset. The org-wide layer stays — it gates the sign-in + // screen itself. + featureRemoteConfig.clearRoleScope(); + + // That same wipe takes the remembered mobile number and the biometric + // flag with it, which the sign-in screen needs. Only those two are + // re-read — a full bootstrap would raise the hydrating flag and blank the + // app behind a loader on the way out. + void dispatch(loadSignInContext()); + } + + wasAuthenticated.current = isAuthenticated; }, [isAuthenticated, dispatch]); useEffect(() => { const unregister = registerUnauthorizedHandler(() => { - dispatch(logoutSession()); - toast('Signed out', 'info', 'For your security, your session was refreshed.'); + // A token the server rejected, not a customer leaving — so the session + // goes and the registration stays. See `LogoutReason`. + void dispatch(logoutSession('unauthorized')); + toast('Signed out', 'info', 'Your session ended. Please sign in again.'); }); return unregister; diff --git a/src/hooks/useSessionTimeout.ts b/src/hooks/useSessionTimeout.ts index 168fdd2..37f966b 100644 --- a/src/hooks/useSessionTimeout.ts +++ b/src/hooks/useSessionTimeout.ts @@ -1,37 +1,182 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; -import { useAppDispatch } from '../app/hooks'; -import { logoutSession } from '../modules/auth/store/authThunk'; +import { useAppDispatch, useAppSelector } from '../app/hooks'; +import { appConfig } from '../config/appConfig'; +import { env } from '../config/env'; +import { logoutSession, refreshSessionIdentity } from '../modules/auth/store/authThunk'; +import { + selectHasLiveSession, + selectIsAuthenticated, +} from '../modules/auth/store/authSelectors'; import { uiActions } from '../modules/ui/store/uiSlice'; import { sessionManager } from '../services/security/sessionManager'; import { useToast } from './useToast'; +const TICK_MS = 5_000; + +type LockReason = 'idle' | 'absolute' | 'background'; + +const LOCK_COPY: Record = { + absolute: { + title: 'Session expired', + body: 'Sessions end after an hour. Please sign in again.', + tone: 'error', + }, + idle: { + title: 'Session expired', + body: 'You were inactive for a while. Please sign in again.', + tone: 'error', + }, + // Not a failure — the app asked for the MPIN again on purpose, so it should + // not be dressed up as something going wrong. + background: { + title: 'Welcome back', + body: 'Enter your MPIN to continue.', + tone: 'info', + }, +}; + +/** + * Ends the session when it should end, and asks for the MPIN again when it + * should be asked for. Three triggers: + * + * - the idle window the server reported, which any authenticated call resets; + * - the absolute ceiling it never extends; + * - returning to the foreground after the app was away long enough to count as + * being opened again. + * + * The first two run on a timer rather than only on app-state changes: the + * server starts answering 401 the moment the idle window closes, and a customer + * halfway through a transfer should be warned beforehand rather than discover it + * as a failed action. + */ export const useSessionTimeout = () => { const dispatch = useAppDispatch(); const toast = useToast(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const hasLiveSession = useAppSelector(selectHasLiveSession); + const warnedRef = useRef(false); + /** When the app last left the foreground, for the background lock below. */ + const leftForegroundAt = useRef(null); + + const expire = useCallback( + (reason: LockReason) => { + // Only the timeouts are an expiry the user should be warned about; a + // deliberate re-lock is not, and flagging it would train them to ignore + // the banner that matters. + if (reason !== 'background') { + dispatch(uiActions.setSessionExpired(true)); + } + + // 'expired', not a sign-out: the customer stepped away, they did not + // leave. Wiping the remembered profile here would cost them an OTP and a + // device re-bind to get back into an account they are still using. + void dispatch(logoutSession('expired')); + + const copy = LOCK_COPY[reason]; + toast(copy.title, copy.tone, copy.body); + }, + [dispatch, toast], + ); + + useEffect(() => { + if (!isAuthenticated) { + warnedRef.current = false; + return; + } + + const interval = setInterval(() => { + const reason = sessionManager.expiryReason(); + + if (reason) { + clearInterval(interval); + expire(reason); + return; + } + + // Someone using the app should not be signed out for being idle. Only an + // authenticated call moves the server's window, and most screens here + // read from fixtures, so the keep-alive has to be sent deliberately. + if (hasLiveSession && sessionManager.shouldKeepAlive()) { + void dispatch(refreshSessionIdentity()); + } + + const remaining = sessionManager.getRemainingMs(); + + if (!warnedRef.current && remaining <= env.sessionWarningMs) { + warnedRef.current = true; + + // Against the idle window, carrying on using the app genuinely helps. + // Against the absolute cap nothing does, so saying "tap to stay signed + // in" there would be a lie the user finds out about a minute later. + const isAbsolute = sessionManager.nearestLimit() === 'absolute'; + + toast( + isAbsolute ? 'Session ending soon' : 'Still there?', + 'info', + isAbsolute + ? 'Sessions end an hour after signing in. Finish up and sign in again.' + : 'Your session is about to end. Tap anywhere to stay signed in.', + ); + } + + if (warnedRef.current && remaining > env.sessionWarningMs) { + // The idle timer was reset by an authenticated call, so the next + // approach to the cut-off deserves its own warning. + warnedRef.current = false; + } + }, TICK_MS); + + return () => clearInterval(interval); + }, [dispatch, expire, hasLiveSession, isAuthenticated, toast]); useEffect(() => { const handleAppStateChange = (nextState: AppStateStatus) => { - if (nextState === 'active') { - if (sessionManager.hasExpired()) { - dispatch(uiActions.setSessionExpired(true)); - dispatch(logoutSession()); - toast('Session expired', 'error', 'Please log in again to continue securely.'); - } - - sessionManager.touch(); + if (nextState === 'background' || nextState === 'inactive') { + leftForegroundAt.current = Date.now(); + return; } - if (nextState === 'background' || nextState === 'inactive') { - sessionManager.touch(); + if (nextState !== 'active') { + return; + } + + const awayMs = + leftForegroundAt.current === null ? 0 : Date.now() - leftForegroundAt.current; + + leftForegroundAt.current = null; + + const reason = sessionManager.expiryReason(); + + if (reason) { + expire(reason); + return; + } + + // The Android process usually survives backgrounding, so re-opening the + // app does not re-run bootstrap. Without this, "ask for the MPIN on every + // open" would hold only for a cold start. + if ( + isAuthenticated && + appConfig.security.requireMpinOnLaunch && + awayMs >= appConfig.security.lockAfterBackgroundMs + ) { + expire('background'); + return; + } + + // Coming back from the background is also the cheapest moment to notice + // a session revoked elsewhere: one active session per profile, so signing + // in on another device signed this one out. Only meaningful for a live + // session — the local agent one has no server to ask. + if (hasLiveSession) { + void dispatch(refreshSessionIdentity()); } }; const subscription = AppState.addEventListener('change', handleAppStateChange); - return () => { - subscription.remove(); - }; - }, [dispatch, toast]); + return () => subscription.remove(); + }, [dispatch, expire, hasLiveSession, isAuthenticated]); }; diff --git a/src/modules/agent/components/AgentEmptyState.tsx b/src/modules/agent/components/AgentEmptyState.tsx index e08cc27..1334065 100644 --- a/src/modules/agent/components/AgentEmptyState.tsx +++ b/src/modules/agent/components/AgentEmptyState.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; interface AgentEmptyStateProps { title: string; @@ -58,7 +58,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 18, - fontWeight: '700', + fontFamily: fontFamily.bold, marginBottom: 8, textAlign: 'center', }, @@ -75,6 +75,6 @@ const styles = StyleSheet.create({ }, buttonText: { fontSize: 14, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, }); diff --git a/src/modules/agent/components/AgentMiniChart.tsx b/src/modules/agent/components/AgentMiniChart.tsx index 3d92813..5e9737c 100644 --- a/src/modules/agent/components/AgentMiniChart.tsx +++ b/src/modules/agent/components/AgentMiniChart.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { View, StyleSheet, Text } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; interface AgentMiniChartProps { data: number[]; @@ -100,6 +100,6 @@ const styles = StyleSheet.create({ flex: 1, textAlign: 'center', fontSize: 10, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, }); diff --git a/src/modules/agent/components/AgentProgressRing.tsx b/src/modules/agent/components/AgentProgressRing.tsx index d61b7dd..f29cc27 100644 --- a/src/modules/agent/components/AgentProgressRing.tsx +++ b/src/modules/agent/components/AgentProgressRing.tsx @@ -8,7 +8,7 @@ import Animated, { withDelay, Easing, } from 'react-native-reanimated'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; const AnimatedCircle = Animated.createAnimatedComponent(Circle); @@ -102,12 +102,12 @@ const styles = StyleSheet.create({ }, value: { fontSize: 24, - fontWeight: '800', + fontFamily: fontFamily.bold, letterSpacing: -0.5, }, label: { fontSize: 10, - fontWeight: '600', + fontFamily: fontFamily.semibold, marginTop: 2, }, }); diff --git a/src/modules/agent/components/AgentSectionHeader.tsx b/src/modules/agent/components/AgentSectionHeader.tsx index 5a55520..849cbd6 100644 --- a/src/modules/agent/components/AgentSectionHeader.tsx +++ b/src/modules/agent/components/AgentSectionHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; interface AgentSectionHeaderProps { title: string; @@ -46,7 +46,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: 18, - fontWeight: '700', + fontFamily: fontFamily.bold, }, subtitle: { fontSize: 13, @@ -54,6 +54,6 @@ const styles = StyleSheet.create({ }, action: { fontSize: 14, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, }); diff --git a/src/modules/agent/components/AgentStatCard.tsx b/src/modules/agent/components/AgentStatCard.tsx index f7a0dc2..f2dbbc2 100644 --- a/src/modules/agent/components/AgentStatCard.tsx +++ b/src/modules/agent/components/AgentStatCard.tsx @@ -4,7 +4,7 @@ import Ionicons from '@react-native-vector-icons/ionicons'; import Animated, { FadeInUp } from 'react-native-reanimated'; import LinearGradient from 'react-native-linear-gradient'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; interface AgentStatCardProps { title: string; @@ -158,19 +158,19 @@ const styles = StyleSheet.create({ }, trendText: { fontSize: 10, - fontWeight: '700', + fontFamily: fontFamily.bold, }, body: { gap: 4, }, value: { fontSize: 22, - fontWeight: '800', + fontFamily: fontFamily.bold, letterSpacing: -0.5, }, title: { fontSize: 12, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, trendDesc: { fontSize: 10, diff --git a/src/modules/agent/components/CollectionEntryCard.tsx b/src/modules/agent/components/CollectionEntryCard.tsx index 86c646a..8524921 100644 --- a/src/modules/agent/components/CollectionEntryCard.tsx +++ b/src/modules/agent/components/CollectionEntryCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; import type { CollectionEntry } from '../types/agentTypes'; import { formatAgentCurrency, formatTime } from '../utils/agentFormatters'; @@ -95,7 +95,7 @@ const styles = StyleSheet.create({ }, name: { fontSize: 15, - fontWeight: '700', + fontFamily: fontFamily.bold, }, metaRow: { flexDirection: 'row', @@ -110,10 +110,10 @@ const styles = StyleSheet.create({ }, amount: { fontSize: 15, - fontWeight: '700', + fontFamily: fontFamily.bold, }, mode: { fontSize: 10, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, }); diff --git a/src/modules/agent/components/CommissionSummaryCard.tsx b/src/modules/agent/components/CommissionSummaryCard.tsx index f19ff78..3d208fd 100644 --- a/src/modules/agent/components/CommissionSummaryCard.tsx +++ b/src/modules/agent/components/CommissionSummaryCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; import type { CommissionSummary } from '../types/agentTypes'; import { formatAgentCurrency } from '../utils/agentFormatters'; import { AgentProgressRing } from './AgentProgressRing'; @@ -81,12 +81,12 @@ const styles = StyleSheet.create({ }, title: { fontSize: 13, - fontWeight: '500', + fontFamily: fontFamily.medium, marginBottom: 4, }, amount: { fontSize: 28, - fontWeight: '800', + fontFamily: fontFamily.bold, letterSpacing: -0.5, }, divider: { @@ -104,12 +104,12 @@ const styles = StyleSheet.create({ }, statLabel: { fontSize: 12, - fontWeight: '500', + fontFamily: fontFamily.medium, marginBottom: 4, }, statValue: { fontSize: 16, - fontWeight: '700', + fontFamily: fontFamily.bold, }, vDivider: { width: 1, @@ -126,6 +126,6 @@ const styles = StyleSheet.create({ }, rateText: { fontSize: 12, - fontWeight: '600', + fontFamily: fontFamily.semibold, }, }); diff --git a/src/modules/agent/components/CustomerCard.tsx b/src/modules/agent/components/CustomerCard.tsx index c1e8a74..34ff0b1 100644 --- a/src/modules/agent/components/CustomerCard.tsx +++ b/src/modules/agent/components/CustomerCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; import type { AgentCustomer } from '../types/agentTypes'; import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters'; @@ -133,11 +133,11 @@ const styles = StyleSheet.create({ }, avatarText: { fontSize: 16, - fontWeight: '700', + fontFamily: fontFamily.bold, }, name: { fontSize: 15, - fontWeight: '700', + fontFamily: fontFamily.bold, marginBottom: 2, }, phoneRow: { @@ -163,7 +163,7 @@ const styles = StyleSheet.create({ }, statusText: { fontSize: 10, - fontWeight: '700', + fontFamily: fontFamily.bold, textTransform: 'uppercase', }, divider: { @@ -180,12 +180,12 @@ const styles = StyleSheet.create({ }, detailLabel: { fontSize: 11, - fontWeight: '500', + fontFamily: fontFamily.medium, marginBottom: 4, }, detailValue: { fontSize: 14, - fontWeight: '700', + fontFamily: fontFamily.bold, }, vDivider: { width: 1, diff --git a/src/modules/agent/constants/agentConstants.ts b/src/modules/agent/constants/agentConstants.ts index c083f7f..f0693ae 100644 --- a/src/modules/agent/constants/agentConstants.ts +++ b/src/modules/agent/constants/agentConstants.ts @@ -1,4 +1,5 @@ import type { AgentLoanCategory, CollectionMode, NewAccountType, SmsType } from '../types/agentTypes'; +import type { CategoryHue } from '../../../theme'; export const COLLECTION_MODES: { id: CollectionMode; label: string; icon: string }[] = [ { id: 'cash', label: 'Cash', icon: 'cash-outline' }, @@ -12,13 +13,32 @@ export const ACCOUNT_TYPES: { id: NewAccountType; label: string; description: st { id: 'Savings', label: 'Savings Account', description: 'Standard savings account with interest', icon: 'wallet-outline' }, ]; -export const LOAN_CATEGORIES: { id: AgentLoanCategory; label: string; icon: string; color: string }[] = [ - { id: 'Personal', label: 'Personal Loan', icon: 'person-outline', color: '#3B82F6' }, - { id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', color: '#F59E0B' }, - { id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', color: '#10B981' }, - { id: 'Housing', label: 'Housing Loan', icon: 'home-outline', color: '#6366F1' }, - { id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', color: '#22C55E' }, - { id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', color: '#EF4444' }, +/** + * The loan types an agent can raise a request for. + * + * Each carries a categorical HUE rather than a hex. These are labels, not + * states — nothing about a housing loan is "worse" than a personal one — so + * they resolve through `theme.colors.category`, which hands back a tone that + * is legible on whichever ramp is live plus a matching tint for the chip. + * + * Seven types, seven distinct hues. Business was a Tailwind red and Agriculture + * a second green; a red that means nothing sits badly next to a red that means + * "declined", and two greens next to each other defeat the point of colouring + * them at all. + */ +export const LOAN_CATEGORIES: { + id: AgentLoanCategory; + label: string; + icon: string; + hue: CategoryHue; +}[] = [ + { id: 'Personal', label: 'Personal Loan', icon: 'person-outline', hue: 'blue' }, + { id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', hue: 'amber' }, + { id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', hue: 'green' }, + { id: 'Housing', label: 'Housing Loan', icon: 'home-outline', hue: 'indigo' }, + { id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', hue: 'teal' }, + { id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', hue: 'violet' }, + { id: 'DDS', label: 'Loan Against DDS Deposit', icon: 'wallet-outline', hue: 'sky' }, ]; export const SMS_TYPES: { id: SmsType; label: string; icon: string }[] = [ diff --git a/src/modules/agent/hooks/useCustomerForm.ts b/src/modules/agent/hooks/useCustomerForm.ts index ecd40fb..b79c05e 100644 --- a/src/modules/agent/hooks/useCustomerForm.ts +++ b/src/modules/agent/hooks/useCustomerForm.ts @@ -6,6 +6,7 @@ import { useAppDispatch } from '../../../app/hooks'; import { agentActions } from '../store/agentSlice'; export interface CustomerFormData { + clientId: string; name: string; phone: string; email: string; @@ -18,6 +19,7 @@ export interface CustomerFormData { } const initialData: CustomerFormData = { + clientId: '', name: '', phone: '', email: '', @@ -35,6 +37,7 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => { const [data, setData] = useState( existingCustomer ? { + clientId: existingCustomer.clientId || '', name: existingCustomer.name, phone: existingCustomer.phone, email: existingCustomer.email || '', @@ -64,6 +67,19 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => { } }, [errors]); + // Set multiple fields at once (e.g. after a Client ID lookup) and clear + // any errors on the fields being populated. + const prefill = useCallback((fields: Partial) => { + setData((prev) => ({ ...prev, ...fields })); + setErrors((prev) => { + const newErrors = { ...prev }; + (Object.keys(fields) as (keyof CustomerFormData)[]).forEach((key) => { + delete newErrors[key]; + }); + return newErrors; + }); + }, []); + const validate = useCallback(() => { const result = validateCustomerForm(data); setErrors(result.errors); @@ -103,6 +119,7 @@ export const useCustomerForm = (existingCustomer?: AgentCustomer) => { data, errors, updateField, + prefill, submit, isSubmitting: isAdding || isUpdating, }; diff --git a/src/modules/agent/screens/AccountOpeningScreen.tsx b/src/modules/agent/screens/AccountOpeningScreen.tsx index 000a884..8212d93 100644 --- a/src/modules/agent/screens/AccountOpeningScreen.tsx +++ b/src/modules/agent/screens/AccountOpeningScreen.tsx @@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons'; import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated'; import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants'; import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes'; import { useAccountOpeningForm } from '../hooks/useAccountOpeningForm'; @@ -33,7 +33,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI {i < currentStepIndex ? ( ) : ( - {i + 1} + {i + 1} )} {i < steps.length - 1 && } @@ -48,7 +48,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI style={[ styles.stepLabel, { color: i <= currentStepIndex ? theme.colors.primary : theme.colors.textMuted }, - i === currentStepIndex && { fontWeight: '700' }, + i === currentStepIndex && { fontFamily: fontFamily.bold }, ]} > {s.label} @@ -231,7 +231,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ value={draft.initialDeposit} onChangeText={v => updateField('initialDeposit', v)} keyboardType="number-pad" - leftAccessory={₹} + leftAccessory={₹} /> {(draft.accountType === 'RD' || draft.accountType === 'DDS') && ( updateField('monthlyInstallment', v)} keyboardType="number-pad" - leftAccessory={₹} + leftAccessory={₹} /> )} @@ -295,7 +295,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ Initial Deposit - + {formatAgentCurrency(Number(draft.initialDeposit))} @@ -361,7 +361,7 @@ const styles = StyleSheet.create({ }, localHeaderTitle: { fontSize: 18, - fontWeight: '800', + fontFamily: fontFamily.bold, letterSpacing: 0.5, }, // ── Step indicator ───────────────────────── @@ -401,7 +401,7 @@ const styles = StyleSheet.create({ }, stepLabel: { fontSize: 10, - fontWeight: '600', + fontFamily: fontFamily.semibold, textTransform: 'uppercase', letterSpacing: 0.3, width: 54, @@ -418,7 +418,7 @@ const styles = StyleSheet.create({ }, stepTitle: { fontSize: 20, - fontWeight: '800', + fontFamily: fontFamily.bold, marginBottom: 4, }, stepSubtitle: { @@ -448,7 +448,7 @@ const styles = StyleSheet.create({ }, typeLabel: { fontSize: 16, - fontWeight: '700', + fontFamily: fontFamily.bold, marginBottom: 4, }, typeDesc: { @@ -477,7 +477,7 @@ const styles = StyleSheet.create({ }, sectionDividerText: { fontSize: 12, - fontWeight: '700', + fontFamily: fontFamily.bold, textTransform: 'uppercase', letterSpacing: 0.5, paddingHorizontal: 12, @@ -502,7 +502,7 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 13, lineHeight: 18, - fontWeight: '500', + fontFamily: fontFamily.medium, }, // ── Review card ─────────────────────────── @@ -519,11 +519,11 @@ const styles = StyleSheet.create({ }, reviewLabel: { fontSize: 13, - fontWeight: '500', + fontFamily: fontFamily.medium, }, reviewValue: { fontSize: 14, - fontWeight: '600', + fontFamily: fontFamily.semibold, textAlign: 'right', flexShrink: 1, marginLeft: 12, diff --git a/src/modules/agent/screens/AddEditCustomerScreen.tsx b/src/modules/agent/screens/AddEditCustomerScreen.tsx index c92816c..5e9b16f 100644 --- a/src/modules/agent/screens/AddEditCustomerScreen.tsx +++ b/src/modules/agent/screens/AddEditCustomerScreen.tsx @@ -5,12 +5,14 @@ import Ionicons from '@react-native-vector-icons/ionicons'; import Animated, { FadeInUp } from 'react-native-reanimated'; import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components'; -import { useTheme } from '../../../theme'; +import { useTheme, fontFamily } from '../../../theme'; import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants'; import { useAppSelector } from '../../../app/hooks'; +import { useToast } from '../../../hooks/useToast'; import { selectCustomerById } from '../store/agentSelectors'; import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes'; import { useCustomerForm } from '../hooks/useCustomerForm'; +import { useLazyGetCustomerByClientIdQuery } from '../services/agentApi'; type Props = NativeStackScreenProps; @@ -18,7 +20,7 @@ const FormSection = ({ title, icon, children }: { title: string, icon: string, c const theme = useTheme(); return ( - + @@ -39,7 +41,9 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) { customerId ? selectCustomerById(customerId)(state) : null ); - const { data, errors, updateField, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined); + const { data, errors, updateField, prefill, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined); + const toast = useToast(); + const [lookupClient, { isFetching: isLookingUp }] = useLazyGetCustomerByClientIdQuery(); const isEdit = !!existingCustomer; @@ -50,6 +54,31 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) { } }; + const handleFetchClient = async () => { + const clientId = data.clientId.trim(); + if (!clientId) { + toast('Enter a Client ID first', 'error'); + return; + } + try { + const client = await lookupClient(clientId).unwrap(); + prefill({ + name: client.name, + phone: client.phone, + email: client.email, + address: client.address, + city: client.city, + state: client.state, + pincode: client.pincode, + aadhaarNumber: client.aadhaarNumber, + panNumber: client.panNumber, + }); + toast('Details loaded', 'success', `${client.name}'s profile has been auto-filled.`); + } catch { + toast('Client not found', 'error', 'No client matches this Client ID.'); + } + }; + return ( @@ -57,6 +86,27 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) { + + + Have an existing Client ID? Enter it to auto-fill the customer's details. + + updateField('clientId', v)} + autoCapitalize="characters" + error={errors.clientId} + /> +