Compare commits

..
3 Commits
Author SHA1 Message Date
Abin ToppoandClaude Opus 5 4eeababec8 Hide fixed and recurring deposits on the account details screen
Every action this screen offers — mini statement, e-passbook, re-KYC,
balance — is one the bank refuses on a deposit, and the account information
grid showed an MMID that is permanently "Not allocated" because deposits
cannot take part in IMPS. The home screen already filters its carousel the
same way; this brings the screen it links to into line.

Filtered on type alone, not through `isOperativeAccount`, so a dormant
savings account still appears — it cannot be debited but it is still the
customer's money.

Adds `isEverydayAccount` to carry that rule, with `supportsBalanceEnquiry`
now defined in terms of it: the same set today, kept under its own name
because it states a fact about the endpoint rather than about the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:38:26 +05:30
Abin ToppoandClaude Opus 5 86f9647437 Add account balance enquiry via GET /accounts/balance
The CBS API now exposes a balance endpoint, which it previously did not.
Several comments in the codebase asserted that no such endpoint existed
anywhere in the API; those are corrected here rather than left to mislead.

Balance is fetched lazily, per account, from the Account Details card:

- `accounts.balance` endpoint, `CbsAccountBalance` type, and a
  `getAccountBalance` query with `keepUnusedDataFor: 0` — a balance is the
  most perishable figure in the app and must not outlive its screen.
- The call is triggered by the customer, not by render. Every call reaches
  the core banking system, and a balance should not appear over someone's
  shoulder just for opening a screen.
- `supportsBalanceEnquiry` gates the control by account type only, with no
  status check: a dormant savings account cannot be debited but still has a
  balance. Deposits are excluded because the endpoint does not serve them.
- `ACCOUNT_BALANCE_NOT_AVAILABLE` added to the error catalogue as a
  non-retryable refusal, so the card states it instead of inviting a tap
  that gets the identical answer.

Also carries in-progress work already present in the tree: customer-facing
account type labels (`accountTypeLabel`) applied across the account card,
selector, mini statement, balance enquiry and MMID screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:27:50 +05:30
Abin Toppo 2dcb10775c Update CBS Mobile App codebase 2026-09-21 13:21:25 +05:30
339 changed files with 27135 additions and 16768 deletions
+11 -1
View File
@@ -1 +1,11 @@
{}
{
"ignore_dirs": [
"android/build",
"android/app/build",
"android/app/.cxx",
"android/.gradle",
"android/.kotlin",
"ios/build",
"ios/Pods"
]
}
+509
View File
@@ -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/<domain>/ 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') && <Stack.Screen name="CardDetails" component={CardDetailsScreen} />}
```
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<T> {
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 <token>` 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<FeatureKey, FeatureDefinition>` **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 |
| `<FeatureGate feature={...} mode="all\|any">` | 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 |
| `<DynamicFeatureGate feature="string">` | 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/<domain>/
├── screens/ route components
├── components/ domain-specific presentational pieces
├── hooks/ form/flow logic extracted from screens
├── store/ <domain>Slice.ts · <domain>Thunk.ts · <domain>Selectors.ts
├── services/ <domain>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'`.
+24 -7
View File
@@ -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 (
<>
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
<ActivityTracker>
{/*
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.
*/}
<StatusBar barStyle={theme.statusBarStyle} translucent backgroundColor="transparent" />
<RootNavigator />
<ToastHost />
</>
</ActivityTracker>
);
};
@@ -28,9 +39,15 @@ export default function App() {
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<Provider store={store}>
<ThemeProvider>
<AppShell />
</ThemeProvider>
{/* 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. */}
<FeatureProvider hydrate={hydrateFeatures}>
<ThemeProvider>
<AppShell />
</ThemeProvider>
</FeatureProvider>
</Provider>
</SafeAreaProvider>
</GestureHandlerRootView>
+312
View File
@@ -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`.
+198
View File
@@ -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.
+32 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -6,6 +7,35 @@
<!-- Required for showing notifications on Android 13+ (Notifee). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Reads the SIM's own number so registration can offer it instead of
asking the customer to type it. Both are needed: READ_PHONE_STATE for
the SIM and subscription list, READ_PHONE_NUMBERS for the MSISDN on
Android 13+. Neither is required for the app to work — a handset whose
carrier never provisioned the number, or a customer who declines, falls
back to typing it. -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<!-- react-native-blob-util declares broad storage permissions in its own
manifest, which the merger would otherwise fold into this app's.
Statement PDFs are written to the app's cache directory and handed
straight to a viewer or the share sheet, so nothing here ever touches
shared storage. Removed rather than inherited: a banking app asking
for the whole device's files is a Play Store review question and a
customer-facing one, and a permission that is never exercised is worse
than useless. Removing them is safe only while nothing calls the
library's `fs.dirs.DownloadDir`/`SDCardDir` or its DownloadManager
path — check that before adding either. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove" />
<uses-permission
android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"
tools:node="remove" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
@@ -14,6 +44,7 @@
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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())
},
)
}
@@ -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"
}
}
@@ -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<NativeModule> = listOf(SimCardModule(reactContext))
override fun createViewManagers(
reactContext: ReactApplicationContext,
): List<ViewManager<out View, out ReactShadowNode<*>>> = emptyList()
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android API 28+ blocks plain HTTP, and a release build does not get the
blanket `usesCleartextTraffic` exemption that debug builds do. Without this,
every call to the local CBS backend fails with a generic network error and
looks exactly like the server being down.
Deliberately NOT `<base-config cleartextTrafficPermitted="true">`: that
would drop TLS enforcement for every host the app ever talks to. Only the
development backend and the Metro dev server are listed, so anything else
keeps the platform default of requiring HTTPS.
Before this app ships, the backend needs TLS and this file should go — a
production build must not carry a cleartext exemption for any host.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<!-- CBS backend. Keep in sync with `API_ORIGIN` in src/config/env.ts —
a host missing from this list has its requests dropped with no
error the app can see.
First entry is the one `API_ORIGIN` currently points at; the rest
are addresses this build has been aimed at before. -->
<domain includeSubdomains="true">mobile-banking-backend.lincpay</domain>
<domain includeSubdomains="false">10.209.40.168</domain>
<domain includeSubdomains="false">192.168.6.165</domain>
<domain includeSubdomains="false">192.168.1.11</domain>
<domain includeSubdomains="false">192.168.6.128</domain>
<domain includeSubdomains="false">192.168.1.10</domain>
<!-- Metro, reached over `adb reverse` in a debug build. -->
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<!-- The host machine as seen from an Android emulator. -->
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
+1
View File
@@ -0,0 +1 @@
sdk.dir=C:\\Users\\Lincpay\\AppData\\Local\\Android\\Sdk
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+124 -31
View File
@@ -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();
+5
View File
@@ -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';
+22 -4
View File
@@ -32,6 +32,22 @@
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
<!-- API_ORIGIN in src/config/env.ts is currently the named host below.
NSAllowsLocalNetworking covers bare IP literals (and .local names)
but gives a *named* host nothing: point API_ORIGIN at one with no
entry here and ATS blocks every cleartext call, surfacing as a
generic network error. All of this goes when the backend gets
TLS. -->
<key>NSExceptionDomains</key>
<dict>
<key>mobile-banking-backend.lincpay</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string/>
@@ -51,10 +67,12 @@
<false/>
<key>UIAppFonts</key>
<array>
<string>Nunito-Bold.ttf</string>
<string>Nunito-ExtraBold.ttf</string>
<string>Nunito-Regular.ttf</string>
<string>Nunito-SemiBold.ttf</string>
<string>IBMPlexSans.ttf</string>
<string>IBMPlexSans-Medm.ttf</string>
<string>IBMPlexSans-SmBld.ttf</string>
<string>IBMPlexSans-Bold.ttf</string>
<string>IBMPlexMono.ttf</string>
<string>IBMPlexMono-SmBld.ttf</string>
</array>
</dict>
</plist>
+19 -1
View File
@@ -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);
+80 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+142
View File
@@ -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);
}
+278
View File
@@ -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.`);
+375
View File
@@ -0,0 +1,375 @@
import React, { memo, useState } from 'react';
import { ActivityIndicator, 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';
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
import { formatCurrency } from '../../utils/formatters';
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;
/**
* Asks the owning screen to fetch this account's balance.
*
* Omit it and no balance control is drawn at all — which is what a deposit
* gets, since `GET /accounts/balance` serves SAVINGS and CURRENT only. The
* fetch itself lives in the screen: this is a shared presentational
* primitive and none of them reach into app state.
*/
onCheckBalance?: () => void;
/** The figure, once fetched. Undefined until the customer asks. */
balance?: number;
balanceLoading?: boolean;
/** Already-resolved failure copy. The card renders it, it does not derive it. */
balanceError?: string;
/**
* False for a refusal the bank will repeat — a deposit, or a product it will
* not price. The strip then goes inert rather than inviting a tap that gets
* the identical answer.
*/
balanceRetryable?: boolean;
}
/**
* The hero card for one account.
*
* It leads with what `GET /accounts` can vouch for — which account this is and
* whether it is usable — because that endpoint returns no balance of its own.
* A balance does exist, on `GET /accounts/balance`, but it is a separate live
* call: it appears here only once the customer asks for it, and only when the
* screen supplies `onCheckBalance`.
*
* Both reveals are deliberate acts. The number is masked by default because
* `/accounts` is the only endpoint that hands back an unmasked one, and the
* balance is not fetched on render because every call reaches the core banking
* system — and because a balance should not appear over someone's shoulder
* just for opening a screen.
*/
const AccountCardComponent = ({
account,
accountHolder,
canRevealNumber = true,
onCheckBalance,
balance,
balanceLoading = false,
balanceError,
balanceRetryable = 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 (
<LinearGradient
colors={theme.colors.brandGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
<View style={[styles.brandAccent, { backgroundColor: theme.colors.onBrandSurface }]} />
<View style={styles.topRow}>
<View style={styles.bankInfo}>
<View
style={[styles.bankIconFrame, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="business" size={20} color={theme.colors.onBrandPrimary} />
</View>
<View>
<Text style={[styles.bankName, { color: theme.colors.onBrandPrimary }]}>
CBS Bank
</Text>
<Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}>
{accountTypeLabel(account.accountType).toUpperCase()}
</Text>
</View>
</View>
{/* 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' ? (
<View style={[styles.statusBadge, { backgroundColor: theme.colors.onBrandPrimary }]}>
<Text style={[styles.statusBadgeText, { color: theme.colors.primary }]}>
{account.status}
</Text>
</View>
) : null}
</View>
<View style={styles.mainContent}>
<Text style={[styles.numberLabel, { color: theme.colors.onBrandSecondary }]}>
ACCOUNT NUMBER
</Text>
<View style={styles.numberRow}>
<Text
style={[styles.numberValue, { color: theme.colors.onBrandPrimary }]}
selectable={showFull}
>
{displayNumber}
</Text>
{canRevealNumber ? (
<Pressable
onPress={() => setRevealed((current) => !current)}
style={[styles.revealButton, { backgroundColor: theme.colors.onBrandSurface }]}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={showFull ? 'Hide account number' : 'Show account number'}
>
<Ionicons
name={showFull ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.onBrandPrimary}
/>
</Pressable>
) : null}
</View>
</View>
{onCheckBalance ? (
<Pressable
onPress={onCheckBalance}
disabled={balanceLoading || (Boolean(balanceError) && !balanceRetryable)}
style={[styles.balanceStrip, { backgroundColor: theme.colors.onBrandSurface }]}
accessibilityRole="button"
accessibilityState={{ busy: balanceLoading }}
accessibilityLabel={
balance !== undefined
? `Balance ${formatCurrency(balance)}. Tap to refresh.`
: 'Check balance'
}
>
{balanceLoading ? (
<View style={styles.balanceRow}>
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
Checking with your bank…
</Text>
</View>
) : balanceError ? (
<View style={styles.balanceRow}>
<Ionicons
name="alert-circle-outline"
size={20}
color={theme.colors.onBrandPrimary}
/>
<View style={styles.balanceCopy}>
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
{balanceError}
</Text>
{balanceRetryable ? (
<Text style={[styles.balanceHint, { color: theme.colors.onBrandMuted }]}>
Tap to try again
</Text>
) : null}
</View>
</View>
) : balance !== undefined ? (
<View style={styles.balanceRow}>
<View style={styles.balanceCopy}>
<Text style={[styles.balanceLabel, { color: theme.colors.onBrandMuted }]}>
AVAILABLE BALANCE
</Text>
<Text style={[styles.balanceValue, { color: theme.colors.onBrandPrimary }]}>
{formatCurrency(balance)}
</Text>
</View>
{/* A balance is only true at the instant it was fetched, so the
way to get a fresh one stays on screen next to the figure. */}
<Ionicons name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
</View>
) : (
<View style={styles.balanceRow}>
<Ionicons name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
Check balance
</Text>
<Ionicons
name="chevron-forward"
size={18}
color={theme.colors.onBrandSecondary}
/>
</View>
)}
</Pressable>
) : null}
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
{accountHolder ? 'ACCOUNT HOLDER' : 'IFSC'}
</Text>
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
{accountHolder ?? account.ifsc}
</Text>
</View>
{/* <View style={[styles.footerItem, styles.footerItemEnd]}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
{account.mmid ? 'MMID' : 'IFSC'}
</Text>
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
{account.mmid ?? account.ifsc}
</Text>
</View> */}
</View>
</LinearGradient>
);
};
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',
},
balanceStrip: {
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 14,
justifyContent: 'center',
minHeight: 56,
},
balanceRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
balanceCopy: {
flex: 1,
gap: 2,
},
balanceLabel: {
...textStyles.eyebrow,
fontSize: 9,
letterSpacing: 1,
},
// Mono, like every other figure in the app — the theme's own reason is that
// proportional digits reflow as a number changes.
balanceValue: {
...textStyles.moneyLarge,
fontSize: 22,
},
balanceAction: {
...textStyles.bodyStrong,
flex: 1,
fontSize: 15,
},
balanceHint: {
...textStyles.bodySmall,
fontSize: 12,
},
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);
+136
View File
@@ -0,0 +1,136 @@
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';
import { accountTypeLabel } from '../../modules/home/utils/accountRules';
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 (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
) : null}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
alwaysBounceHorizontal={false}
>
{accounts.map((account) => {
const selected = account.accountNumber === selectedAccountNumber;
return (
<Pressable
key={account.accountNumber}
accessibilityRole="radio"
accessibilityState={{ selected, disabled }}
accessibilityLabel={`${accountTypeLabel(account.accountType)} ending ${account.accountNumber.slice(-4)}`}
disabled={disabled}
onPress={() => 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,
},
]}
>
<Text style={[styles.type, { color: theme.colors.primary }]}>
{accountTypeLabel(account.accountType).toUpperCase()}
</Text>
<Text style={[styles.number, { color: theme.colors.primary }]}>
{`XXXX ${account.accountNumber.slice(-4)}`}
</Text>
{/* 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' ? (
<Text style={[styles.status, { color: theme.colors.danger }]}>
{account.status}
</Text>
) : null}
</Pressable>
);
})}
</ScrollView>
</View>
);
};
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);
+38
View File
@@ -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) => (
<View
style={styles.fill}
onStartShouldSetResponderCapture={() => {
sessionManager.noteInteraction();
return false;
}}
>
{children}
</View>
);
const styles = StyleSheet.create({
fill: {
flex: 1,
},
});
+2 -2
View File
@@ -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,
@@ -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<SegmentedControlOption<ThemeMode>> = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
];
export const AppearanceSetting = () => {
const theme = useTheme();
const { mode, resolved, setMode } = useThemeMode();
return (
<View style={styles.wrapper}>
<SegmentedControl
options={OPTIONS}
value={mode}
onChange={setMode}
accessibilityLabel="App appearance"
/>
{/*
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' ? (
<View style={styles.hintRow}>
<Ionicons
name={resolved === 'dark' ? 'moon-outline' : 'sunny-outline'}
size={14}
color={theme.colors.textMuted}
/>
<Text style={[styles.hint, { color: theme.colors.textMuted }]}>
{`Following your phone, which is set to ${resolved}.`}
</Text>
</View>
) : null}
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
gap: 10,
paddingVertical: 4,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hint: {
...textStyles.caption,
flex: 1,
},
});
-230
View File
@@ -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 (
<LinearGradient
colors={['#124577', '#124577', '#124577']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
{/* Decorative Brand Accent */}
<View style={styles.brandAccent} />
<View style={styles.topRow}>
<View style={styles.bankInfo}>
<View style={styles.bankIconFrame}>
<Ionicons name="business" size={20} color="white" />
</View>
<View>
<Text style={styles.bankName}>CBS Bank</Text>
<Text style={styles.cardType}>{secondaryLabel}</Text>
</View>
</View>
{account.isPrimary && (
<View style={styles.primaryBadge}>
<Text style={styles.primaryBadgeText}>PRIMARY</Text>
</View>
)}
</View>
<View style={styles.mainContent}>
<Text style={styles.maskedNumber}>A/c {account.maskedNumber}</Text>
<View style={styles.balanceContainer}>
<Text style={styles.balanceLabel}>AVAILABLE BALANCE</Text>
<View style={styles.amountRow}>
<Text style={styles.amountValue}>
{isBalanceVisible ? `₹${formattedBalance}` : '₹ ••••••'}
</Text>
<Pressable
onPress={() => setIsBalanceVisible((currentValue) => !currentValue)}
style={styles.visibilityButton}
hitSlop={8}
>
<Ionicons
name={isBalanceVisible ? 'eye-outline' : 'eye-off-outline'}
size={20}
color="rgba(255, 255, 255, 0.7)"
/>
</Pressable>
</View>
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={styles.footerLabel}>ACCOUNT HOLDER</Text>
<Text style={styles.footerValue}>{account.accountHolder}</Text>
</View>
<View style={[styles.footerItem, styles.footerItemEnd]}>
<Text style={styles.footerLabel}>UPI STATUS</Text>
<View style={styles.upiRow}>
<View style={styles.upiDot} />
<Text style={styles.footerValue}>Active</Text>
</View>
</View>
</View>
</LinearGradient>
);
};
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);
+2 -2
View File
@@ -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,
},
});
+165 -98
View File
@@ -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<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
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 = (
<View style={styles.content}>
{loading ? (
<ActivityIndicator color={theme.colors.white} />
) : (
<>
{leftAccessory}
<Text
style={[
styles.label,
{
color:
variant === 'ghost'
? theme.colors.textPrimary
: variant === 'secondary' ? theme.colors.textPrimary : theme.colors.white,
letterSpacing: variant === 'cyber' ? 2 : 0,
textTransform: variant === 'cyber' ? 'uppercase' : 'none',
},
textStyle,
]}
>
{title}
</Text>
</>
)}
</View>
[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 (
<Pressable disabled={disabled || loading} onPress={handlePress} style={containerStyle}>
{isSolid ? (
<LinearGradient
colors={
variant === 'danger'
? [theme.colors.danger, '#FF586E']
: variant === 'cyber'
? ['#2563EB', '#3B82F6']
: [theme.colors.primary, theme.colors.primary]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
accessibilityState={{ disabled: inert, busy: loading }}
disabled={inert}
onPress={handlePress}
onPressIn={() => 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 ? (
<View style={styles.loadingOverlay} pointerEvents="none">
<ActivityIndicator size="small" color={surface.label} />
</View>
) : null}
<View style={[styles.content, loading && styles.contentHidden]}>
{leftAccessory}
<Text
numberOfLines={1}
style={[
styles.gradient,
{
borderRadius: theme.radius.md,
},
theme.text.button,
size === 'sm' && styles.labelSmall,
{ color: surface.label },
textStyle,
]}
>
{content}
</LinearGradient>
) : (
content
)}
{title}
</Text>
{rightAccessory}
</View>
</Pressable>
);
};
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,
},
});
+52 -13
View File
@@ -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<ViewStyle>;
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 (
<View
style={[
styles.card,
theme.shadows.soft,
elevation,
{
backgroundColor: variant === 'glass' ? theme.colors.cardGlass : theme.colors.surface,
borderColor: theme.colors.cardBorder,
backgroundColor,
borderRadius: theme.radius.lg,
padding: noPadding ? 0 : theme.spacing.md,
},
style,
]}
@@ -34,11 +72,12 @@ const CardComponent = ({ children, style, variant = 'default' }: CardProps) => {
);
};
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',
},
});
+16 -17
View File
@@ -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 = ({
</View>
{/* Footer */}
<View style={styles.calendarFooter}>
<View style={[styles.calendarFooter, { borderTopColor: theme.colors.cardBorder }]}>
<Pressable onPress={() => setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.footerBtnText, { color: theme.colors.textMuted }]}>Cancel</Text>
</Pressable>
@@ -299,7 +299,7 @@ export const DatePicker = ({
}}
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontWeight: '700' }]}>Today</Text>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>Today</Text>
</Pressable>
</View>
</>
@@ -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 ─────────────────────────
+2 -2
View File
@@ -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,
+164
View File
@@ -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<typeof Ionicons>['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<ViewStyle>;
}
/**
* 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<InfoBannerTone, IoniconName> = {
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 (
<View
// Announced when it appears, which is the point of an inline notice that
// replaces a toast.
accessibilityRole="alert"
accessibilityLabel={title ? `${title}. ${message}` : message}
style={[
styles.banner,
{
backgroundColor: palette.bg,
borderColor: palette.border,
borderRadius: theme.radius.md,
padding: theme.spacing.sm,
gap: theme.spacing.sm,
},
style,
]}
>
<Ionicons
name={icon ?? DEFAULT_ICON[tone]}
size={18}
color={palette.fg}
style={styles.icon}
/>
<View style={styles.body}>
{title ? (
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>{title}</Text>
) : 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. */}
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>{message}</Text>
{actionLabel && onActionPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={actionLabel}
onPress={onActionPress}
// A text link inside a banner is the easiest place to ship a 20pt
// target by accident; the hitSlop is what brings it to 44.
hitSlop={12}
style={styles.action}
>
<Text style={[theme.text.button, { color: palette.fg }]}>{actionLabel}</Text>
<Ionicons name="chevron-forward" size={15} color={palette.fg} />
</Pressable>
) : null}
</View>
</View>
);
};
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);
+157 -60
View File
@@ -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<TextInputProps['onFocus']> = useCallback(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = () => {
focused.value = withTiming(0, { duration: 250 });
};
const handleBlur: NonNullable<TextInputProps['onBlur']> = 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 (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{label.toUpperCase()}
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
{label}
{required ? <Text style={{ color: theme.colors.danger }}> *</Text> : null}
</Text>
)}
<Animated.View style={[styles.inputWrapper, containerAnimatedStyle]}>
{leftAccessory && <View style={styles.accessory}>{leftAccessory}</View>}
) : null}
<View
style={[
styles.field,
{
minHeight: theme.layout.controlHeight,
borderRadius: theme.radius.md,
borderColor,
backgroundColor,
paddingHorizontal: theme.spacing.md,
},
// A multiline field cannot live inside a fixed 52pt row, so the
// wrapper grows and its content aligns to the top instead of centring.
multiline && styles.fieldMultiline,
]}
>
{leftAccessory ? <View style={styles.accessoryLeft}>{leftAccessory}</View> : null}
{prefix ? (
<View style={styles.prefix}>
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
{prefix}
</Text>
<View style={[styles.prefixRule, { backgroundColor: theme.colors.cardBorder }]} />
</View>
) : null}
<TextInput
{...props}
editable={editable}
multiline={multiline}
onFocus={handleFocus}
onBlur={handleBlur}
placeholderTextColor={theme.colors.textMuted}
style={[styles.input, { color: theme.colors.textPrimary }]}
accessibilityLabel={props.accessibilityLabel ?? label}
style={[
theme.text.body,
styles.input,
multiline && styles.inputMultiline,
{ color: editable ? theme.colors.textPrimary : theme.colors.textSecondary },
style,
]}
/>
{rightAccessory && <View style={styles.accessory}>{rightAccessory}</View>}
</Animated.View>
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
{rightAccessory ? <View style={styles.accessoryRight}>{rightAccessory}</View> : null}
</View>
{/* 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 ? (
<View style={styles.message}>
<Ionicons name="alert-circle" size={14} color={theme.colors.danger} />
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
{error}
</Text>
</View>
) : hint ? (
<View style={styles.message}>
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.textMuted }]}>
{hint}
</Text>
</View>
) : null}
</View>
);
};
@@ -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,
},
});
+2 -2
View File
@@ -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,
+35 -26
View File
@@ -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}
</ScrollView>
);
/**
* 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 = (
<View style={styles.flex}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
{children}
</View>
</TouchableWithoutFeedback>
<View
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
{children}
</View>
);
const content = scrollable ? scrollContent : staticContent;
const innerContent = keyboardAware ? (
const innerContent = usesKeyboardAvoidingView ? (
<KeyboardAvoidingView
behavior={kavBehavior}
behavior="padding"
style={styles.flex}
keyboardVerticalOffset={totalOffset}
>
@@ -124,11 +137,7 @@ export const ScreenShell = ({
const container = (
<SafeAreaView edges={['top']} style={styles.flex}>
{/* ✅ Status Bar Added */}
<StatusBar
barStyle={'dark-content'}
backgroundColor="transparent"
translucent
/>
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
<View style={[styles.frame, transparent && styles.transparentFrame]}>
{title ? (
+179
View File
@@ -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<T extends string> {
value: T;
label: string;
}
interface SegmentedControlProps<T extends string> {
options: ReadonlyArray<SegmentedControlOption<T>>;
value: T;
onChange: (value: T) => void;
disabled?: boolean;
/** `lg` raises the track to the 48pt agent target. */
size?: 'md' | 'lg';
style?: StyleProp<ViewStyle>;
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<T extends string>({
options,
value,
onChange,
disabled = false,
size = 'md',
style,
accessibilityLabel,
}: SegmentedControlProps<T>) {
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 (
<View
accessibilityRole="tablist"
accessibilityLabel={accessibilityLabel}
onLayout={handleLayout}
style={[
styles.track,
{
height,
padding: inset,
borderRadius: theme.radius.pill,
backgroundColor: theme.colors.surfaceMuted,
borderColor: theme.colors.cardBorder,
opacity: disabled ? 0.6 : 1,
},
style,
]}
>
{/* Rendered only once the track has been measured — an unmeasured thumb
would flash at zero width on the first frame. */}
{segmentWidth > 0 ? (
<Animated.View
pointerEvents="none"
style={[
styles.thumb,
theme.elevation.level1,
{
width: segmentWidth,
borderRadius: theme.radius.pill,
backgroundColor: theme.colors.surface,
},
thumbStyle,
]}
/>
) : null}
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
accessibilityRole="tab"
accessibilityState={{ selected: active, disabled }}
accessibilityLabel={option.label}
disabled={disabled}
onPress={() => handlePress(option.value)}
style={styles.segment}
>
<Text
numberOfLines={1}
style={[
active ? theme.text.bodyStrong : theme.text.body,
styles.label,
{
color: active ? theme.colors.primary : theme.colors.textSecondary,
},
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
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,
},
});
+181
View File
@@ -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<ViewStyle>;
}
/**
* 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 (
<View
accessibilityRole="progressbar"
accessibilityLabel={`Step ${current + 1} of ${steps.length}: ${steps[current] ?? ''}`}
style={[styles.container, style]}
>
{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 (
<View key={label} style={styles.step}>
{/* 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. */}
<View style={styles.railRow}>
<View
style={[
styles.rail,
{ backgroundColor: index === 0 ? 'transparent' : railColour },
]}
/>
<View
style={[
styles.marker,
{ backgroundColor: markerFill, borderColor: markerBorder },
]}
>
{done ? (
<Ionicons name="checkmark" size={13} color={markerInk} />
) : (
<Text style={[theme.text.eyebrow, styles.markerText, { color: markerInk }]}>
{index + 1}
</Text>
)}
</View>
<View
style={[
styles.rail,
{
backgroundColor: index === steps.length - 1 ? 'transparent' : railColour,
},
]}
/>
</View>
<Text
numberOfLines={2}
style={[
active ? theme.text.bodyStrong : theme.text.bodySmall,
styles.label,
{ color: labelColour },
]}
>
{label}
</Text>
</View>
);
})}
</View>
);
};
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);
+2 -2
View File
@@ -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,
+51 -32
View File
@@ -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 (
<View
style={[
styles.wrapper,
{
borderBottomColor: 'rgba(255, 255, 255, 0.05)',
},
]}
>
<View style={[styles.wrapper, { borderBottomColor: theme.colors.cardBorder }]}>
<View style={styles.leading}>
<View
style={[
styles.icon,
{
backgroundColor: isCredit
? `${theme.colors.success}10`
: 'rgba(255, 255, 255, 0.05)',
borderColor: isCredit ? `${theme.colors.success}30` : 'rgba(255, 255, 255, 0.1)',
? theme.colors.successSurface
: theme.colors.surfaceMuted,
borderColor: isCredit ? theme.colors.successBorder : theme.colors.cardBorder,
},
]}
>
<Ionicons
name={isCredit ? 'chevron-down' : 'chevron-up'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.white}
color={isCredit ? theme.colors.success : theme.colors.textSecondary}
/>
</View>
<View style={styles.copy}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: theme.colors.white }]}>{item.title}</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{item.title}</Text>
</View>
<View style={styles.subtitleRow}>
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
{item.counterparty}
</Text>
<View style={styles.dot} />
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
<View style={[styles.dot, { backgroundColor: theme.colors.textMuted }]} />
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
{formatDate(item.occurredAt)}
</Text>
</View>
@@ -80,13 +91,18 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
<Text
style={[
styles.amount,
{ color: isCredit ? theme.colors.success : theme.colors.white },
{ color: isCredit ? theme.colors.success : theme.colors.textPrimary },
]}
>
{isCredit ? '+' : '-'}
{formatCurrency(item.amount)}
</Text>
<View style={[styles.riskBadge, { backgroundColor: `${riskConfig.color}10`, borderColor: `${riskConfig.color}20` }]}>
<View
style={[
styles.riskBadge,
{ backgroundColor: riskConfig.surface, borderColor: riskConfig.border },
]}
>
<Text style={[styles.riskText, { color: riskConfig.color }]}>{riskConfig.label}</Text>
</View>
</View>
@@ -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,
},
});
+7 -1
View File
@@ -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';
+22
View File
@@ -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,
+43 -2
View File
@@ -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,
};
+325
View File
@@ -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<string> = new Set<string>(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;
};
+88
View File
@@ -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 "© <year> <legalName>". */
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.',
],
},
];
+18
View File
@@ -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[] = [
+1
View File
@@ -8,6 +8,7 @@ export const QUERY_TAGS = [
'Payments',
'Deposits',
'Services',
'ChequeBooks',
'Loans',
'LoanActive',
'LoanApplications',
+103
View File
@@ -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.
*
* <FeatureGate feature={FeatureKey.PAYMENTS_BENEFICIARIES_ADD}>
* <AddBeneficiaryButton />
* </FeatureGate>
*
* 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;
};
+159
View File
@@ -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<FeatureContextValue | undefined>(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<FeatureOverrides | undefined>;
/** Loads per-tenant/role overrides once the session is known. */
loadTenantOverrides?: () => Promise<FeatureOverrides | undefined>;
/**
* 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<void>;
}>;
/**
* 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<FeatureOverrides | undefined>) | 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<FeatureContextValue>(
() => ({ isEnabled: isFeatureEnabled, ready, version }),
[ready, version],
);
return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
};
/**
* 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 <FeatureProvider>.');
}
return context;
};
+65
View File
@@ -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],
},
];
+291
View File
@@ -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<FeatureKey, …>` 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<FeatureKey, FeatureDefinition> = {
// ---------------------------------------------------------------- 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 = {};
+124
View File
@@ -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<string, unknown>)) {
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<FeatureOverrides> {
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<void> {
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<void> {
featureService.patchSource(FeatureSource.User, key, undefined);
await persist(this.current());
},
/** Drops every local override. The "reset to defaults" action. */
async resetAll(): Promise<void> {
featureService.setSource(FeatureSource.User, {});
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
if (__DEV__) {
console.warn('[features] Could not clear stored overrides.', error);
}
}
},
};
+226
View File
@@ -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<string, boolean> | 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<CachedConfig | null> => {
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<string, boolean>, 'cached config'),
roleOverrides: narrow(parsed.roleOverrides as Record<string, boolean>, '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<FeatureConfigResult> => {
const response = await apiClient.get<FeatureConfigResult>(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<boolean> {
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<boolean> {
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: {} });
},
};
+262
View File
@@ -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([]);
});
});
+415
View File
@@ -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<FeatureKey, FeatureConfig>();
private readonly layers = new Map<FeatureSource, FeatureOverrides>();
private readonly cache = new Map<FeatureKey, boolean>();
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<FeatureKey, boolean> {
const snapshot = {} as Record<FeatureKey, boolean>;
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<TArgs extends unknown[], TResult>(
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<T extends { feature?: FeatureKey | string }>(
items: readonly T[],
): T[] {
return items.filter((item) => item.feature === undefined || isFeatureKeyEnabled(item.feature));
}
+29
View File
@@ -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<void> => {
await featureOverrides.restore();
await featureRemoteConfig.applyCached();
await featureRemoteConfig.refresh({ useMock: env.useMockServer });
};
+45
View File
@@ -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';
+117
View File
@@ -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<Record<FeatureKey, boolean>>;
/**
* 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;
};
+130
View File
@@ -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 && <AddBeneficiaryButton />}</>;
*/
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 = <T extends readonly FeatureKey[]>(
keys: T,
): Record<T[number], boolean> => {
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<T[number], boolean>;
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 = <TArgs extends unknown[]>(
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 = <T extends { feature?: FeatureKey | string }>(
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],
);
};
+35
View File
@@ -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],
);
};
+45 -12
View File
@@ -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<typeof useAppDispatch>) => {
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;
+162 -17
View File
@@ -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<LockReason, { title: string; body: string; tone: 'error' | 'info' }> = {
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<number | null>(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]);
};
@@ -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,
},
});
@@ -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,
},
});
@@ -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,
},
});
@@ -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,
},
});
@@ -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,
@@ -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,
},
});
@@ -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,
},
});
@@ -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,
+27 -7
View File
@@ -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 }[] = [
@@ -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<CustomerFormData>(
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<CustomerFormData>) => {
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,
};
@@ -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 ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontWeight: '700' }}>{i + 1}</Text>
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontFamily: fontFamily.bold }}>{i + 1}</Text>
)}
</View>
{i < steps.length - 1 && <View style={[styles.stepLine, { backgroundColor: i < currentStepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />}
@@ -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={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
{(draft.accountType === 'RD' || draft.accountType === 'DDS') && (
<Input
@@ -240,7 +240,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
value={draft.monthlyInstallment}
onChangeText={v => updateField('monthlyInstallment', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
)}
</View>
@@ -295,7 +295,7 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Initial Deposit</Text>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontWeight: '800', fontSize: 16 }]}>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontFamily: fontFamily.bold, fontSize: 16 }]}>
{formatAgentCurrency(Number(draft.initialDeposit))}
</Text>
</View>
@@ -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,
@@ -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<AgentCustomersStackParamList, 'AddEditCustomer'>;
@@ -18,7 +20,7 @@ const FormSection = ({ title, icon, children }: { title: string, icon: string, c
const theme = useTheme();
return (
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionHeader, { borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
</View>
@@ -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) {
<View style={styles.flexContainer}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
<FormSection title="Client ID" icon="card-outline">
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
Have an existing Client ID? Enter it to auto-fill the customer's details.
</Text>
<Input
label="Client ID (Optional)"
placeholder="e.g. CL-100245"
value={data.clientId}
onChangeText={(v) => updateField('clientId', v)}
autoCapitalize="characters"
error={errors.clientId}
/>
<Button
title="Fetch Details"
variant="secondary"
onPress={handleFetchClient}
loading={isLookingUp}
leftAccessory={<Ionicons name="search-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FormSection>
<FormSection title="Personal Information" icon="person-outline">
<Input
label="Full Name"
@@ -73,7 +123,7 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
keyboardType="number-pad"
maxLength={10}
error={errors.phone}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>+91</Text>}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontFamily: fontFamily.bold }}>+91</Text>}
/>
<Input
label="Email Address (Optional)"
@@ -183,7 +233,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
sectionIcon: {
width: 32,
@@ -195,13 +244,17 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sectionContent: {
padding: 16,
paddingBottom: 16,
gap: 16,
},
helperText: {
fontSize: 13,
lineHeight: 18,
},
row: {
flexDirection: 'row',
},
@@ -7,7 +7,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector, useAppDispatch } from '../../../app/hooks';
import type { AgentTabParamList, AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
import { useAgentDashboard } from '../hooks/useAgentDashboard';
@@ -166,7 +166,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
title="Today's Collection"
value={formatAgentCurrency(stats.todayCollection)}
icon="wallet-outline"
gradient={['#2563EB', '#3B82F6']}
gradient={['#2563EB', theme.colors.info]}
onPress={goToCollections}
/>
<View style={{ width: 12 }} />
@@ -175,7 +175,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
title="This Week"
value={formatAgentCurrency(stats.weeklyCollection)}
icon="trending-up-outline"
gradient={['#059669', '#10B981']}
gradient={[theme.colors.success, theme.colors.success]}
onPress={goToCollectionHistory}
/>
</View>
@@ -196,7 +196,7 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
/>
</View>
<View style={styles.chartDivider} />
<View style={[styles.chartDivider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.chartSubtitle, { color: theme.colors.textMuted }]}>Last 7 Days Trend</Text>
<AgentMiniChart
@@ -253,12 +253,12 @@ const styles = StyleSheet.create({
},
greeting: {
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
name: {
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
avatarFrame: {
@@ -292,22 +292,21 @@ const styles = StyleSheet.create({
},
chartTitle: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 4,
},
chartValue: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
chartDivider: {
height: 1,
backgroundColor: 'rgba(0,0,0,0.05)',
marginVertical: 16,
},
chartSubtitle: {
fontSize: 11,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 12,
},
footerSpacer: {
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch } from '../../../app/hooks';
import { logoutSession } from '../../auth/store/authThunk';
import { Button } from '../../../components/Button';
@@ -48,7 +48,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontFamily: fontFamily.bold,
},
subtitle: {
fontSize: 16,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import LoanRequestListScreen from './LoanRequestListScreen';
@@ -94,7 +94,7 @@ const styles = StyleSheet.create({
},
headerTitle: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
tabContainer: {
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
},
tabLabel: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
contentArea: {
flex: 1,
@@ -7,7 +7,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
@@ -25,8 +25,8 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
const handleLogout = () => {
Alert.alert(
'Log Out',
'Are you sure you want to log out of your agent account?',
'Log out of this device?',
'Signing out clears everything this app has stored on this phone, not just your session.',
[
{ text: 'Cancel', style: 'cancel' },
{
@@ -153,11 +153,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
name: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
role: {
@@ -179,11 +179,11 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
@@ -214,7 +214,7 @@ const styles = StyleSheet.create({
},
settingsLabel: {
fontSize: 15,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
divider: {
height: 1,
+121 -64
View File
@@ -1,100 +1,157 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { View, Text, StyleSheet, Share } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useToast } from '../../../hooks/useToast';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentDashboardStackParamList, 'AgentRoute'>;
export default function AgentRouteScreen({ navigation }: Props) {
type Stop = {
id: string;
name: string;
area: string;
time: string;
status: 'done' | 'next' | 'pending';
};
const STOPS: Stop[] = [
{ id: '1', name: 'Ramesh Kumar', area: 'MG Road, Shop 14', time: '9:30 AM', status: 'done' },
{ id: '2', name: 'Sunita Devi', area: 'Gandhi Nagar, H-22', time: '10:15 AM', status: 'done' },
{ id: '3', name: 'Imran Shaikh', area: 'Market Lane, 7B', time: '11:00 AM', status: 'next' },
{ id: '4', name: 'Lakshmi Bai', area: 'Station Road, 41', time: '11:45 AM', status: 'pending' },
{ id: '5', name: 'Vikram Patel', area: 'Old Town, 9', time: '12:30 PM', status: 'pending' },
];
export default function AgentRouteScreen(_props: Props) {
const theme = useTheme();
const toast = useToast();
const statusColor = (status: Stop['status']) =>
status === 'done' ? theme.colors.success : status === 'next' ? theme.colors.primary : theme.colors.textMuted;
const handleShare = async () => {
const summary = STOPS.map((stop, index) => `${index + 1}. ${stop.name} — ${stop.area} (${stop.time})`).join('\n');
try {
await Share.share({
title: "Today's collection route",
message: `Today's collection route (${STOPS.length} stops):\n\n${summary}`,
});
} catch {
toast('Unable to share', 'error', 'Could not open the share sheet. Please try again.');
}
};
return (
<ScreenShell title="My Route" scrollable={false}>
<View style={styles.container}>
<View style={styles.mapPlaceholder}>
<Ionicons name="map-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.mapText, { color: theme.colors.textMuted }]}>Map Integration Coming Soon</Text>
<ScreenShell title="My Route" subtitle="Today's collection route" contentContainerStyle={styles.content}>
{/* Map preview */}
<View style={[styles.mapPlaceholder, { borderColor: theme.colors.cardBorder, backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="map-outline" size={48} color={theme.colors.primary} />
<Text style={[styles.mapText, { color: theme.colors.textSecondary }]}>Route overview · 5 stops · 8.4 km</Text>
<View style={styles.pinRow}>
{STOPS.map((stop) => (
<View key={stop.id} style={[styles.pin, { backgroundColor: statusColor(stop.status) }]} />
))}
</View>
</View>
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.infoTitle, { color: theme.colors.textPrimary }]}>Route Summary</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total Stops</Text>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
</View>
{/* Summary */}
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total stops</Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statCol}>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
</View>
</View>
<Button
title="Start Navigation"
onPress={() => {}}
style={{ marginTop: 'auto', marginBottom: 24 }}
{/* Stops list */}
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Stops</Text>
<View style={[styles.stopsCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{STOPS.map((stop, index) => (
<View key={stop.id} style={[styles.stopRow, index !== STOPS.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.stopDot, { backgroundColor: `${statusColor(stop.status)}20` }]}>
<Ionicons
name={stop.status === 'done' ? 'checkmark' : 'location'}
size={14}
color={statusColor(stop.status)}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.stopName, { color: theme.colors.textPrimary }]}>{stop.name}</Text>
<Text style={[styles.stopArea, { color: theme.colors.textSecondary }]}>{stop.area}</Text>
</View>
<Text style={[styles.stopTime, { color: theme.colors.textMuted }]}>{stop.time}</Text>
</View>
))}
</View>
{/* Actions */}
<View style={styles.actionRow}>
<Button
title="Start Navigation"
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Opening maps', 'info', 'Launching turn-by-turn navigation for your route.')}
leftAccessory={<Ionicons name="navigate-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Share Route"
variant="secondary"
fullWidth={false}
style={styles.actionBtn}
onPress={handleShare}
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={{ height: 32 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
content: { gap: 16 },
mapPlaceholder: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.02)',
height: 160,
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
mapText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
gap: 10,
},
mapText: { fontSize: 14, fontFamily: fontFamily.semibold },
pinRow: { flexDirection: 'row', gap: 8 },
pin: { width: 10, height: 10, borderRadius: 5 },
infoCard: {
flexDirection: 'row',
borderRadius: 16,
borderWidth: 1,
padding: 16,
alignItems: 'center',
},
infoTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
statLabel: {
fontSize: 14,
},
statValue: {
fontSize: 16,
fontWeight: '700',
},
statCol: { flex: 1, alignItems: 'center', gap: 4 },
statDivider: { width: 1, height: 32 },
statValue: { fontSize: 20, fontFamily: fontFamily.bold },
statLabel: { fontSize: 12 },
sectionTitle: { fontSize: 16, fontFamily: fontFamily.bold, marginLeft: 4 },
stopsCard: { borderRadius: 16, borderWidth: 1, paddingHorizontal: 16 },
stopRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
stopDot: { width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center' },
stopName: { fontSize: 14, fontFamily: fontFamily.bold },
stopArea: { fontSize: 12, fontFamily: fontFamily.medium },
stopTime: { fontSize: 12, fontFamily: fontFamily.semibold },
actionRow: { flexDirection: 'row', gap: 12 },
actionBtn: { flex: 1 },
});
@@ -4,7 +4,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetCollectionHistoryQuery } from '../services/agentApi';
@@ -93,12 +93,12 @@ const styles = StyleSheet.create({
},
summaryLabel: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
summaryValue: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { formatFullCurrency, formatDateTime } from '../utils/agentFormatters';
@@ -36,7 +36,7 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
styles.infoValue,
{
color: strong ? theme.colors.textPrimary : theme.colors.textSecondary,
fontWeight: strong ? '700' : '500'
fontFamily: strong ? fontFamily.bold : fontFamily.medium
}
]}>
{value}
@@ -130,12 +130,12 @@ const styles = StyleSheet.create({
},
successTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 8,
},
successAmount: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
dividerContainer: {
flexDirection: 'row',
@@ -199,6 +199,6 @@ const styles = StyleSheet.create({
},
actionBtnText: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -3,7 +3,7 @@ import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useGetCommissionSummaryQuery } from '../services/agentApi';
@@ -132,13 +132,13 @@ const styles = StyleSheet.create({
totalLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
totalValue: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
iconCircle: {
@@ -161,11 +161,11 @@ const styles = StyleSheet.create({
lastPaidValue: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
historyTitle: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 12,
},
historyCard: {
@@ -181,7 +181,7 @@ const styles = StyleSheet.create({
},
historyMonth: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
statusBadge: {
paddingHorizontal: 8,
@@ -190,7 +190,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
@@ -210,6 +210,6 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});

Some files were not shown because too many files have changed in this diff Show More