Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+240
View File
@@ -0,0 +1,240 @@
import React, { memo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { CbsAccount } from '../../types/cbs';
import { useTheme, textStyles } from '../../theme';
interface AccountCardProps {
account: CbsAccount;
/**
* The customer's name, from the session. Passed in rather than read here:
* this is a shared presentational primitive and none of them reach into app
* state. It is also not account data — `/accounts` returns no holder name.
*/
accountHolder?: string;
/**
* Whether the reveal control for the account number is offered. When false
* the number stays masked with no way to reveal it.
*/
canRevealNumber?: boolean;
}
/**
* The hero card for one account.
*
* This was a balance card until the app moved to `GET /accounts`, which
* returns no balance — and no other CBS endpoint returns one either. Rather
* than print a figure the bank never sent, the card now leads with what it can
* actually vouch for: which account this is, and whether it is usable.
*
* The account number is masked by default. `/accounts` is the only endpoint
* that returns an unmasked one, so revealing it is a deliberate act.
*/
const AccountCardComponent = ({
account,
accountHolder,
canRevealNumber = true,
}: AccountCardProps) => {
const theme = useTheme();
const [revealed, setRevealed] = useState(false);
const showFull = revealed && canRevealNumber;
const displayNumber = showFull
? account.accountNumber
: `XXXX ${account.accountNumber.slice(-4)}`;
return (
<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 }]}>
{`${account.accountType.toUpperCase()} ACCOUNT`}
</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>
<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',
},
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);
+135
View File
@@ -0,0 +1,135 @@
import React, { memo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme, fontFamily } from '../../theme';
import type { CbsAccount } from '../../types/cbs';
interface AccountSelectorProps {
/** The accounts to offer, as `GET /accounts` returned them. */
accounts: CbsAccount[];
/** The chosen account, addressed by its number — these have no local id. */
selectedAccountNumber?: string;
onSelect: (accountNumber: string) => void;
/** Section label above the row — "Debit from", "Credit to", "Drawn on account". */
label?: string;
/** Greys the row out and stops selection while a request is in flight. */
disabled?: boolean;
}
/**
* The account chooser that heads every account-scoped form.
*
* A row of chips rather than a dropdown: this app's customers hold two or three
* accounts, so every option fits on screen and the choice costs one tap instead
* of two plus a modal.
*
* Selection is drawn with a border and a tinted fill, never with colour alone —
* a chip that differed only in hue would be invisible to a customer who cannot
* separate the two, and this is the control that decides which account the
* money leaves.
*
* Accounts are shown masked. The unmasked number `GET /accounts` returns is
* carried in the value, never painted onto a chip.
*/
const AccountSelectorComponent = ({
accounts,
selectedAccountNumber,
onSelect,
label,
disabled = false,
}: AccountSelectorProps) => {
const theme = useTheme();
if (!accounts.length) {
return null;
}
return (
<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={`${account.accountType} account 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 }]}>
{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,
},
});
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInRight, FadeOutLeft, FadeIn } 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 type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useLoanRequestForm } from '../hooks/useLoanRequestForm';
@@ -46,7 +46,7 @@ const StepIndicator = ({ step }: { step: number }) => {
<Text style={{
color: isActive ? theme.colors.white : theme.colors.textMuted,
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
}}>{s.id}</Text>
)}
</View>
@@ -54,7 +54,7 @@ const StepIndicator = ({ step }: { step: number }) => {
styles.stepLabel,
{
color: isActive ? theme.colors.primary : isCompleted ? theme.colors.success : theme.colors.textMuted,
fontWeight: isActive ? '700' : '500',
fontFamily: isActive ? fontFamily.bold : fontFamily.medium,
},
]}>{s.label}</Text>
</View>
@@ -103,6 +103,12 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
const [step, setStep] = useState(1);
const selectedCategory = LOAN_CATEGORIES.find(c => c.id === data.category);
// The chosen type's categorical pair — a legible tone and a measured tint —
// for whichever theme is live. Falls back to the brand tint before a type is
// picked, so the banner is never drawn with an undefined colour.
const selectedTone = selectedCategory
? theme.colors.category[selectedCategory.hue]
: { tone: theme.colors.primary, surface: theme.colors.primarySurface };
const handleNext = () => {
if (step === 1 && !data.category) return;
@@ -185,29 +191,40 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
<View style={styles.categoryList}>
{LOAN_CATEGORIES.map(cat => {
const isSelected = data.category === cat.id;
// The hue resolves to a tone and a measured tint here,
// where the theme exists. The tint replaces the old
// `${hex}12` / `${hex}20` alpha suffixes, which composited
// the raw hue over the card and so worked on white only.
const { tone, surface } = theme.colors.category[cat.hue];
return (
<Pressable
key={cat.id}
style={[
styles.catCard,
{
backgroundColor: isSelected ? `${cat.color}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? cat.color : 'transparent',
backgroundColor: isSelected ? surface : theme.colors.surfaceMuted,
borderColor: isSelected ? tone : 'transparent',
borderWidth: isSelected ? 1.5 : 1,
},
]}
onPress={() => updateField('category', cat.id as any)}
>
<View style={[styles.catIcon, { backgroundColor: `${cat.color}20` }]}>
<Ionicons name={cat.icon as any} size={22} color={cat.color} />
<View style={[styles.catIcon, { backgroundColor: surface }]}>
<Ionicons name={cat.icon as any} size={22} color={tone} />
</View>
<Text style={[
styles.catLabel,
{ color: isSelected ? cat.color : theme.colors.textPrimary },
{ color: isSelected ? tone : theme.colors.textPrimary },
]}>{cat.label}</Text>
{isSelected && (
<View style={[styles.catCheck, { backgroundColor: cat.color }]}>
<Ionicons name="checkmark" size={12} color={theme.colors.white} />
<View style={[styles.catCheck, { backgroundColor: tone }]}>
{/* On a saturated fill, so this takes the on-fill
tone — white in light mode, dark ink in dark. */}
<Ionicons
name="checkmark"
size={12}
color={theme.colors.textOnAccentFill}
/>
</View>
)}
</Pressable>
@@ -234,7 +251,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('customerPhone', v)}
keyboardType="number-pad"
maxLength={10}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontWeight: '600', fontSize: 13 }}>+91</Text>}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontFamily: fontFamily.semibold, fontSize: 13 }}>+91</Text>}
/>
</SectionCard>
</Animated.View>
@@ -244,12 +261,18 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
{step === 2 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{selectedCategory && (
<Animated.View entering={FadeIn} style={[styles.selectedBanner, { backgroundColor: `${selectedCategory.color}12`, borderColor: `${selectedCategory.color}30` }]}>
<View style={[styles.bannerIcon, { backgroundColor: `${selectedCategory.color}20` }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedCategory.color} />
<Animated.View
entering={FadeIn}
style={[
styles.selectedBanner,
{ backgroundColor: selectedTone.surface, borderColor: selectedTone.tone },
]}
>
<View style={[styles.bannerIcon, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerTitle, { color: selectedTone.tone }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerSub, { color: theme.colors.textMuted }]}>for {data.customerName}</Text>
</View>
</Animated.View>
@@ -263,7 +286,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('amount', v)}
keyboardType="number-pad"
error={errors.amount}
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '800', color: theme.colors.primary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 16, fontFamily: fontFamily.bold, color: theme.colors.primary }}>₹</Text>}
/>
<View style={styles.row}>
<View style={styles.col}>
@@ -274,7 +297,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('tenureMonths', v)}
keyboardType="number-pad"
error={errors.tenureMonths}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontWeight: '600' }}>months</Text>}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontFamily: fontFamily.semibold }}>months</Text>}
/>
</View>
<View style={{ width: 12 }} />
@@ -286,7 +309,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onChangeText={v => updateField('monthlyIncome', v)}
keyboardType="number-pad"
error={errors.monthlyIncome}
leftAccessory={<Text style={{ fontSize: 14, fontWeight: '700', color: theme.colors.textMuted }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 14, fontFamily: fontFamily.bold, color: theme.colors.textMuted }}>₹</Text>}
/>
</View>
</View>
@@ -311,9 +334,11 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{/* Header badge */}
{selectedCategory && (
<View style={[styles.reviewBadge, { backgroundColor: `${selectedCategory.color}12` }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedCategory.color} />
<Text style={[styles.reviewBadgeText, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<View style={[styles.reviewBadge, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
<Text style={[styles.reviewBadgeText, { color: selectedTone.tone }]}>
{selectedCategory.label}
</Text>
</View>
)}
@@ -416,7 +441,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 17,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
sectionSubtitle: {
@@ -453,7 +478,7 @@ const styles = StyleSheet.create({
},
catLabel: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'center',
lineHeight: 14,
},
@@ -486,7 +511,7 @@ const styles = StyleSheet.create({
},
bannerTitle: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
bannerSub: {
fontSize: 12,
@@ -503,7 +528,7 @@ const styles = StyleSheet.create({
errorText: {
fontSize: 12,
marginTop: 6,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
// ── Review Card ───────────────────────────
@@ -524,7 +549,7 @@ const styles = StyleSheet.create({
},
reviewBadgeText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
reviewDivider: {
height: 1,
@@ -538,12 +563,12 @@ const styles = StyleSheet.create({
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
fontFamily: fontFamily.medium,
minWidth: 80,
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
textAlign: 'right',
flexShrink: 1,
marginLeft: 16,
@@ -560,7 +585,7 @@ const styles = StyleSheet.create({
},
docsTitle: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 3,
},
docsText: {
@@ -597,7 +622,7 @@ const styles = StyleSheet.create({
},
progressText: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
minWidth: 60,
},
progressTrack: {
@@ -5,7 +5,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 { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import { useDeleteAgentCustomerMutation } from '../services/agentApi';
@@ -13,6 +13,8 @@ import type { AgentCustomersStackParamList } from '../navigation/agentNavigation
import { formatAgentCurrency, formatDate, maskAadhaar, maskPhone } from '../utils/agentFormatters';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useFeatureAction } from '../../../features';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerDetail'>;
@@ -55,6 +57,14 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
);
};
// Second line of defence: even if some path reached this handler while
// `agent.customers.delete` is off, it returns before the confirm dialog and
// before any delete request is issued.
const handleDeleteCustomer = useFeatureAction(
FeatureKey.AGENT_CUSTOMERS_DELETE,
handleDelete,
);
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
<View style={styles.infoRow}>
<View style={styles.infoLabelArea}>
@@ -105,12 +115,14 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_EDIT}>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
</FeatureGate>
</View>
</Animated.View>
@@ -170,16 +182,18 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
</Animated.View>
{/* Delete Area */}
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDelete}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_DELETE}>
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDeleteCustomer}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
</FeatureGate>
<View style={styles.footerSpacer} />
</ScrollView>
@@ -209,14 +223,14 @@ const styles = StyleSheet.create({
},
avatarBigText: {
fontSize: 32,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
headerInfo: {
alignItems: 'center',
},
headerName: {
fontSize: 22,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
headerPhone: {
@@ -230,7 +244,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
actionButtonsRow: {
flexDirection: 'row',
@@ -250,7 +264,7 @@ const styles = StyleSheet.create({
},
iconLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
section: {
marginBottom: 24,
@@ -267,11 +281,11 @@ const styles = StyleSheet.create({
},
accType: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
fontFamily: fontFamily.mono,
},
divider: {
height: 1,
@@ -292,7 +306,7 @@ const styles = StyleSheet.create({
},
finValue: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
vDivider: {
width: 1,
@@ -314,7 +328,7 @@ const styles = StyleSheet.create({
},
infoValue: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
flex: 1,
textAlign: 'right',
},
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
@@ -73,7 +73,7 @@ export default function CustomerInstallmentHistoryScreen({ route, navigation }:
return (
<ScreenShell title="Installment History" scrollable={false}>
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: theme.colors.cardBorder }]}>
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>
{customer.accounts[0]?.type} A/C: {customer.accounts[0]?.accountNumber}
@@ -96,16 +96,15 @@ const styles = StyleSheet.create({
padding: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
customerName: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
fontFamily: fontFamily.mono,
},
listContent: {
padding: 16,
@@ -133,7 +132,7 @@ const styles = StyleSheet.create({
},
date: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 2,
},
receipt: {
@@ -144,11 +143,11 @@ const styles = StyleSheet.create({
},
amount: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
status: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -4,10 +4,10 @@ import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } 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, AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
@@ -20,6 +20,8 @@ import type { CustomerFilter } from '../types/agentTypes';
import { CustomerCard } from '../components/CustomerCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCustomersStackParamList, 'CustomerList'>,
@@ -143,8 +145,12 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
onAction={searchQuery ? () => handleSearch('') : undefined}
/>
)}
// No `exiting` here: FlatList unmounts cells for virtualisation as well
// as for deletion, and an exit animation makes Reanimated hold the
// outgoing native view while Fabric is recycling that cell — which
// crashes with "addViewAt: the specified child already has a parent".
renderItem={({ item, index }) => (
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))} exiting={FadeOut}>
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))}>
<CustomerCard
customer={item}
onPress={() => navigation.navigate('CustomerDetail', { customerId: item.id })}
@@ -156,12 +162,14 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
</View>
{/* FAB to add new customer */}
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_CREATE}>
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
</FeatureGate>
</ScreenShell>
);
}
@@ -205,7 +213,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listArea: {
flex: 1,
@@ -8,7 +8,7 @@ import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
import { ScreenShell, Button, Input } 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, AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
@@ -64,7 +64,7 @@ const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selecte
onChangeText={setAmount}
keyboardType="number-pad"
error={formError || undefined}
leftAccessory={<Text style={{ fontSize: 18, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
leftAccessory={<Text style={{ fontSize: 18, fontFamily: fontFamily.bold, color: theme.colors.textPrimary }}>₹</Text>}
/>
<Text style={[styles.modeLabel, { color: theme.colors.textMuted }]}>Payment Mode</Text>
@@ -314,13 +314,13 @@ const styles = StyleSheet.create({
summaryLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginBottom: 4,
},
summaryValue: {
color: '#fff',
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
},
progressTrack: {
@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
offlineText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listArea: {
flex: 1,
@@ -361,11 +361,11 @@ const styles = StyleSheet.create({
},
listTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
historyLink: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
filterRow: {
flexDirection: 'row',
@@ -381,7 +381,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
listContent: {
padding: 16,
@@ -411,11 +411,11 @@ const styles = StyleSheet.create({
},
avatarText: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
entryName: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
entryMeta: {
@@ -433,11 +433,11 @@ const styles = StyleSheet.create({
collectBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
entryAmount: {
fontSize: 15,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
statusBadge: {
paddingHorizontal: 8,
@@ -446,7 +446,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
sheetContainer: {
flex: 1,
@@ -457,7 +457,7 @@ const styles = StyleSheet.create({
},
sheetTitle: {
fontSize: 20,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginBottom: 24,
},
sheetCustomerInfo: {
@@ -465,7 +465,7 @@ const styles = StyleSheet.create({
},
sheetCustomerName: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 4,
},
sheetCustomerMeta: {
@@ -473,7 +473,7 @@ const styles = StyleSheet.create({
},
modeLabel: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginBottom: 12,
marginTop: 16,
},
@@ -493,6 +493,6 @@ const styles = StyleSheet.create({
},
modeText: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -4,7 +4,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { SlideInRight } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
@@ -114,7 +114,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
newBtn: {
flexDirection: 'row',
@@ -126,7 +126,7 @@ const styles = StyleSheet.create({
},
newBtnText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
listContent: {
padding: 16,
@@ -148,7 +148,7 @@ const styles = StyleSheet.create({
},
customerName: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
marginBottom: 2,
},
category: {
@@ -161,7 +161,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
@@ -178,6 +178,6 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
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 { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { useGetSmsLogsQuery } from '../services/agentApi';
@@ -106,7 +106,7 @@ const styles = StyleSheet.create({
},
phone: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
headerRight: {
flexDirection: 'row',
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
},
statusText: {
fontSize: 10,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
divider: {
height: 1,
+24
View File
@@ -2,6 +2,7 @@ import { baseApi } from '../../../services/api/baseApi';
import type {
AgentDashboardStats,
AgentCustomer,
ClientDirectoryRecord,
CollectionEntry,
CommissionSummary,
AgentLoanRequest,
@@ -12,6 +13,7 @@ import type {
import {
MOCK_DASHBOARD_STATS,
MOCK_CUSTOMERS,
MOCK_CLIENT_DIRECTORY,
MOCK_TODAY_COLLECTIONS,
MOCK_COMMISSION_SUMMARY,
MOCK_LOAN_REQUESTS,
@@ -42,11 +44,32 @@ export const agentApi = baseApi.injectEndpoints({
providesTags: ['AgentCustomers'],
}),
// Look up a pre-registered client by Client ID to auto-fill the form.
getCustomerByClientId: builder.query<ClientDirectoryRecord, string>({
queryFn: async (clientId) => {
await delay(500);
const normalized = clientId.trim().toLowerCase();
const match = MOCK_CLIENT_DIRECTORY.find(
(c) => c.clientId.toLowerCase() === normalized,
);
if (!match) {
return {
error: {
status: 404,
message: 'No client found for this Client ID',
},
};
}
return { data: match };
},
}),
addAgentCustomer: builder.mutation<AgentCustomer, Partial<AgentCustomer>>({
queryFn: async (customer) => {
await delay(400);
const newCustomer: AgentCustomer = {
id: `c${Date.now()}`,
clientId: customer.clientId,
name: customer.name ?? '',
phone: customer.phone ?? '',
email: customer.email,
@@ -223,6 +246,7 @@ export const agentApi = baseApi.injectEndpoints({
export const {
useGetAgentDashboardQuery,
useGetAgentCustomersQuery,
useLazyGetCustomerByClientIdQuery,
useAddAgentCustomerMutation,
useUpdateAgentCustomerMutation,
useDeleteAgentCustomerMutation,
+17 -1
View File
@@ -68,6 +68,7 @@ export interface CustomerAccount {
export interface AgentCustomer {
id: string;
clientId?: string;
name: string;
phone: string;
email?: string;
@@ -86,6 +87,21 @@ export interface AgentCustomer {
joinedDate: string;
}
// Pre-registered client record looked up by Client ID so the agent can
// auto-fill a new customer's details instead of re-entering everything.
export interface ClientDirectoryRecord {
clientId: string;
name: string;
phone: string;
email: string;
address: string;
city: string;
state: string;
pincode: string;
aadhaarNumber: string;
panNumber: string;
}
// ─── Collection ──────────────────────────────────────────────────────────────
export type CollectionStatus = 'collected' | 'pending' | 'partial' | 'skipped';
export type CollectionMode = 'cash' | 'upi' | 'bank_transfer';
@@ -161,7 +177,7 @@ export interface CommissionSummary {
// ─── Loan Request ────────────────────────────────────────────────────────────
export type LoanRequestStatus = 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'disbursed';
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business';
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business' | 'DDS';
export interface AgentLoanRequest {
id: string;
+43
View File
@@ -2,6 +2,7 @@ import type {
AgentProfile,
AgentDashboardStats,
AgentCustomer,
ClientDirectoryRecord,
CollectionEntry,
CommissionSummary,
CommissionRecord,
@@ -102,6 +103,48 @@ export const MOCK_CUSTOMERS: AgentCustomer[] = [
makeCustomer('c012', 'Anita Patel', '9876543012', 'active', 0, 19000, 500),
];
// Existing clients in the core banking system, keyed by Client ID. When an
// agent enters one of these IDs while adding a customer, the full profile is
// fetched and the form is auto-filled.
export const MOCK_CLIENT_DIRECTORY: ClientDirectoryRecord[] = [
{
clientId: 'CL-100245',
name: 'Rohit Malhotra',
phone: '9876501245',
email: 'rohit.malhotra@email.com',
address: '45, Park Street',
city: 'Kolkata',
state: 'West Bengal',
pincode: '700016',
aadhaarNumber: '432109876543',
panNumber: 'BMNPK4521R',
},
{
clientId: 'CL-100312',
name: 'Sneha Banerjee',
phone: '9831004412',
email: 'sneha.banerjee@email.com',
address: '12B, Salt Lake Sector V',
city: 'Kolkata',
state: 'West Bengal',
pincode: '700091',
aadhaarNumber: '785412369087',
panNumber: 'CDKPB7788L',
},
{
clientId: 'CL-100489',
name: 'Imran Khan',
phone: '9903112278',
email: 'imran.khan@email.com',
address: '7, Lake Town',
city: 'Howrah',
state: 'West Bengal',
pincode: '711101',
aadhaarNumber: '109283746501',
panNumber: 'EFLPK2345M',
},
];
const makeCollectionEntry = (
id: string, customerId: string, customerName: string, phone: string,
emiAmount: number, collected: number, status: 'collected' | 'pending' | 'partial',
+139 -58
View File
@@ -1,81 +1,162 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, Pressable, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
interpolate
} from 'react-native-reanimated';
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface BiometricNodeProps {
onPress: () => void;
/**
* `hero` is the large centred target for the full-bleed navy sign-in screen,
* where biometrics are the main way in. `tile` is the compact row for a light
* surface, where biometrics are the alternative to an MPIN already on screen.
*/
variant?: 'hero' | 'tile';
/**
* Override when the platform tells you which sensor is enrolled — "Use Face
* ID", "Use fingerprint". The default stays generic because naming the wrong
* sensor is worse than not naming one.
*/
label?: string;
/** Only shown by `hero`, beneath the target. */
caption?: string;
disabled?: boolean;
}
export const BiometricNode = ({ onPress }: BiometricNodeProps) => {
/**
* The biometric sign-in affordance.
*
* It used to be a 64pt circle scaling between 1.0 and 1.05 on an infinite
* repeat. That ran a Reanimated loop for as long as the screen was open, drew
* the eye away from the field most customers actually use, and read like a game
* prompt rather than a bank's. Both shapes below are still, with the press
* itself as the only motion.
*/
const BiometricNodeComponent = ({
onPress,
variant = 'tile',
label = 'Use biometrics',
caption = 'Tap to sign in',
disabled = false,
}: BiometricNodeProps) => {
const theme = useTheme();
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withRepeat(
withTiming(1.05, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
}, []);
if (variant === 'hero') {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Signs you in using the fingerprint or face unlock on this device"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={styles.hero}
>
{({ pressed }) => (
<>
<View style={styles.frame}>
{/* Pressing brightens rather than dims. Fading an element on a
navy wash reduces its contrast against the wash, which is the
wrong direction for feedback on a sign-in control. */}
<Ionicons
name="scan-outline"
size={148}
color={pressed ? theme.colors.onBrandPrimary : theme.colors.onBrandSecondary}
/>
<View style={styles.frameCentre} pointerEvents="none">
<Ionicons name="finger-print" size={52} color={theme.colors.onBrandPrimary} />
</View>
</View>
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<View style={styles.container}>
<Pressable onPress={onPress} style={styles.pressable}>
<Animated.View style={[styles.node, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }, animatedStyle]}>
<Ionicons name="finger-print-outline" size={32} color={theme.colors.primary} />
</Animated.View>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Use Biometrics</Text>
{caption ? (
<Text style={[theme.text.body, { color: theme.colors.onBrandSecondary }]}>
{caption}
</Text>
) : null}
</>
)}
</Pressable>
</View>
);
}
/**
* The compact shape. Rendered as a full-width tile rather than a bare icon:
* the icon alone gave no hint of what tapping it would do, and a 56pt row is
* a target a thumb finds without aiming.
*/
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Signs you in using the fingerprint or face unlock on this device"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.tile,
{
minHeight: theme.layout.controlHeightAgent,
borderRadius: theme.radius.md,
borderColor: pressed ? theme.colors.primary : theme.colors.cardBorderStrong,
backgroundColor: pressed ? theme.colors.primarySurface : theme.colors.surface,
paddingHorizontal: theme.spacing.md,
gap: theme.spacing.sm,
opacity: disabled ? 0.55 : 1,
},
]}
>
<View
style={[
styles.iconPlate,
{
backgroundColor: theme.colors.primarySurface,
borderRadius: theme.radius.sm,
},
]}
>
<Ionicons name="finger-print" size={20} color={theme.colors.primary} />
</View>
<Text style={[theme.text.button, styles.tileLabel, { color: theme.colors.primary }]}>
{label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
hero: {
alignItems: 'center',
marginTop: 8,
gap: 14,
},
pressable: {
alignItems: 'center',
gap: 10,
},
node: {
width: 64,
height: 64,
borderRadius: 32,
borderWidth: 1,
frame: {
alignItems: 'center',
justifyContent: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 2,
},
}),
},
label: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
// Overlays the fingerprint on the centre of the scan brackets. Absolute
// rather than a nested child because the glyph's own box has padding that
// would otherwise offset it.
frameCentre: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
tile: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
},
iconPlate: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
},
tileLabel: {
flex: 1,
},
});
export const BiometricNode = memo(BiometricNodeComponent);
+186
View File
@@ -0,0 +1,186 @@
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface CodeInputProps {
value: string;
onChangeText: (value: string) => void;
/** How many boxes to draw. An MPIN may be 4 or 6, an OTP is always 6. */
length: number;
label?: string;
error?: string;
/** Masks each digit, for an MPIN rather than an OTP. */
secure?: boolean;
autoFocus?: boolean;
onSubmit?: () => void;
editable?: boolean;
}
/**
* A segmented digit field: one real input, hidden behind boxes that show what
* has been typed. Keeps paste, backspace and the platform's SMS autofill
* working, which a box-per-input implementation loses.
*/
export const CodeInput = ({
value,
onChangeText,
length,
label,
error,
secure = false,
autoFocus = false,
onSubmit,
editable = true,
}: CodeInputProps) => {
const theme = useTheme();
const inputRef = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
useEffect(() => {
if (autoFocus) {
// A frame's grace, otherwise the keyboard races the screen transition and
// sometimes never appears.
const timer = setTimeout(() => inputRef.current?.focus(), 250);
return () => clearTimeout(timer);
}
}, [autoFocus]);
const handleChange = (next: string) => {
onChangeText(next.replace(/\D/g, '').slice(0, length));
};
const boxes = Array.from({ length }, (_, index) => index);
const activeIndex = Math.min(value.length, length - 1);
return (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
) : null}
{/* The hidden input is scoped to this wrapper rather than the whole
component, so it cannot swallow a tap meant for the error text. */}
<View style={styles.field}>
<Pressable
accessibilityRole="none"
onPress={() => inputRef.current?.focus()}
style={styles.boxRow}
>
{boxes.map((index) => {
const filled = index < value.length;
const isActive = focused && index === activeIndex && editable;
return (
<View
key={index}
style={[
styles.box,
{
borderRadius: theme.radius.md,
// A filled box keeps a stronger border than an empty one, so
// progress through the code is legible without relying on
// the dot alone — which matters at arm's length in sunlight.
borderColor: error
? theme.colors.danger
: isActive
? theme.colors.primary
: filled
? theme.colors.cardBorderStrong
: theme.colors.cardBorder,
borderWidth: isActive ? 1.5 : 1,
backgroundColor: error
? theme.colors.dangerSurface
: editable
? theme.colors.surface
: theme.colors.surfaceMuted,
},
]}
>
{filled ? (
secure ? (
<View style={[styles.dot, { backgroundColor: theme.colors.textPrimary }]} />
) : (
<Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>
{value[index]}
</Text>
)
) : null}
</View>
);
})}
</Pressable>
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSubmitEditing={onSubmit}
keyboardType="number-pad"
editable={editable}
maxLength={length}
autoComplete={secure ? 'off' : 'sms-otp'}
textContentType={secure ? 'none' : 'oneTimeCode'}
caretHidden
// The boxes are decoration as far as a screen reader is concerned;
// this is the field, so it carries the label and the progress.
accessibilityLabel={label ?? (secure ? 'MPIN' : 'One-time password')}
accessibilityValue={{ text: `${value.length} of ${length} digits entered` }}
style={styles.hiddenInput}
/>
</View>
{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>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 8,
},
field: {
position: 'relative',
},
boxRow: {
flexDirection: 'row',
gap: 10,
},
box: {
flex: 1,
aspectRatio: 0.85,
maxHeight: 60,
alignItems: 'center',
justifyContent: 'center',
},
dot: {
width: 12,
height: 12,
borderRadius: 6,
},
// Kept in the tree but invisible: it holds focus and the real value, while
// the boxes above are only a rendering of it.
hiddenInput: {
...StyleSheet.absoluteFillObject,
opacity: 0,
},
message: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
},
messageText: {
flex: 1,
},
});
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { BottomSheet } from '../../../components';
import { useTheme } from '../../../theme';
import { company, copyrightLine, privacy, terms, type LegalSection } from '../../../constants/legalContent';
/**
* The company and legal line at the foot of the sign-in screen.
*
* Two jobs, and they are separate ones. Naming the entity and its regulator is
* a trust signal — on a banking login it is the thing that distinguishes the
* real app from a convincing copy of it, so it is stated plainly rather than
* hidden a tap away. The terms and privacy links are the consent record: this
* is the last screen before a customer signs in, so it is where the agreement
* has to be reachable.
*
* The documents open in a sheet rather than by navigating. Sign-in is
* pre-auth — the legal screen inside the app lives in the profile stack, which
* is not mounted yet — so a link that navigated would either need the route
* duplicated into the auth stack or would simply fail. A sheet also keeps the
* customer on the screen they were signing in on, with whatever they had
* already typed intact.
*/
const LegalBody = ({ sections }: { sections: LegalSection[] }) => {
const theme = useTheme();
// A plain View, not a ScrollView: BottomSheet already wraps its children in
// one, and two same-direction scrollers nested inside each other is the
// documented way to get a list that will not scroll. The sheet is capped at
// 80% of the screen and its own scroller handles a long policy.
return (
<View style={styles.sheetContent}>
{sections.map((section) => (
<View key={section.heading} style={styles.section}>
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
{section.heading}
</Text>
{section.body.map((paragraph) => (
<Text
key={paragraph}
style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}
>
{paragraph}
</Text>
))}
</View>
))}
<Text style={[theme.text.caption, styles.regulator, { color: theme.colors.textMuted }]}>
{company.regulator}
</Text>
</View>
);
};
export const LoginFooter = () => {
const theme = useTheme();
const [open, setOpen] = useState<'terms' | 'privacy' | null>(null);
return (
<View style={styles.wrapper}>
{/*
One sentence, not two links floating on their own: the agreement is
what signing in means, so the sentence says so and the links sit inside
it. `onBrandMuted` is the quietest on-brand tone that still clears AA
on the wash (4.80:1) — this is fine print, and it should read as fine
print without becoming unreadable.
*/}
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
By signing in you agree to our{' '}
{/* Solid white on the links, so the two tappable spans are separable
from the sentence around them by more than colour alone — they also
carry an underline, which is what survives a colour-blind reading. */}
<Text
accessibilityRole="link"
onPress={() => setOpen('terms')}
style={[styles.link, { color: theme.colors.onBrandPrimary }]}
>
Terms &amp; Conditions
</Text>{' '}
and{' '}
<Text
accessibilityRole="link"
onPress={() => setOpen('privacy')}
style={[styles.link, { color: theme.colors.onBrandPrimary }]}
>
Privacy Policy
</Text>
</Text>
{/* The entity and its regulator. Quietest tier, centred, and deliberately
last: it is a statement of record, not something to act on. */}
<View style={styles.identity}>
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
{copyrightLine()}
</Text>
<Text style={[theme.text.caption, styles.centre, { color: theme.colors.onBrandMuted }]}>
{company.regulator}
</Text>
</View>
<BottomSheet
visible={open !== null}
title={open === 'privacy' ? 'Privacy Policy' : 'Terms & Conditions'}
onClose={() => setOpen(null)}
>
<LegalBody sections={open === 'privacy' ? privacy : terms} />
</BottomSheet>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
gap: 8,
paddingHorizontal: 24,
paddingBottom: 4,
},
centre: {
textAlign: 'center',
},
link: {
textDecorationLine: 'underline',
},
identity: {
gap: 2,
},
sheetContent: {
gap: 18,
paddingBottom: 8,
},
section: {
gap: 6,
},
regulator: {
textAlign: 'center',
paddingTop: 4,
},
});
-106
View File
@@ -1,106 +0,0 @@
import React, { memo, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
FadeInDown,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
const CBSNexoraLogo = () => {
return (
<View style={styles.logoGrid}>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#1E40AF' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#3B82F6' }]} />
</View>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#10B981' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#CBD5E1' }]} />
</View>
</View>
);
};
const LoginHeroComponent = () => {
const theme = useTheme();
return (
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(800).springify()}
style={styles.logoWrapper}
>
<CBSNexoraLogo />
<Text style={[styles.brandName, { color: theme.colors.textPrimary }]}>
CBS<Text style={{ color: theme.colors.primary }}>Nexora</Text>
</Text>
</Animated.View>
<Animated.View entering={FadeInDown.delay(200).duration(800).springify()} style={styles.textContainer}>
<Text style={[styles.welcome, { color: theme.colors.textPrimary }]}>Secure Banking</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Enter your credentials to access your global accounts.
</Text>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
gap: 24,
},
logoWrapper: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
logoGrid: {
gap: 3,
},
logoRow: {
flexDirection: 'row',
gap: 3,
},
logoSquare: {
width: 16,
height: 16,
borderRadius: 3,
},
brandName: {
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
textContainer: {
alignItems: 'center',
gap: 6,
paddingHorizontal: 40,
},
welcome: {
fontSize: 24,
fontWeight: '700',
textAlign: 'center',
},
subtitle: {
fontSize: 15,
textAlign: 'center',
fontWeight: '400',
lineHeight: 22,
},
});
export const LoginHero = memo(LoginHeroComponent);
+33 -29
View File
@@ -1,45 +1,49 @@
import React from 'react';
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../../theme';
export function LoginHero() {
/**
* The brand lock-up at the top of the sign-in screen.
*
* There is no logo file in the repo, so the mark is drawn from the Ionicons
* `shield-checkmark` glyph — a shield with a tick, which is the mark the design
* system specifies.
*
* It sits on the navy wash, so it is drawn in `onBrand` white (7.12:1 at the
* lightest point of the gradient) rather than in `primary`, which would be
* navy on navy.
*
* Deliberately a single quiet row rather than a stacked plate-and-headline.
* The sign-in screen's large type belongs to the customer's own name, and a
* hero that competes with it pushes the MPIN field under the keyboard on the
* 360x640 handsets that are the common case here. It also carries no slogan:
* this screen is a lock, and marketing copy on a lock erodes the trust it
* needs.
*/
const LoginHeroComponent = () => {
const theme = useTheme();
return (
<View style={styles.container}>
<View style={[styles.logoFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="shield-checkmark" size={28} color={theme.colors.primary} />
</View>
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>BankCBS</Text>
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>Banking made effortless for your everyday money management.</Text>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
<Text
accessibilityRole="header"
style={[theme.text.h2, { color: theme.colors.onBrandPrimary }]}
>
BankCBS
</Text>
</View>
);
}
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 22,
gap: 12,
gap: 8,
},
logoFrame: {
width: 72,
height: 72,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
heading: {
fontSize: 28,
fontWeight: '800',
textAlign: 'center',
},
description: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
maxWidth: 320,
},
});
});
export const LoginHero = memo(LoginHeroComponent);
+43 -85
View File
@@ -1,98 +1,56 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
import React, { useMemo } from 'react';
import { SegmentedControl } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
import type { LoginRole } from '../../../types/app';
interface LoginRoleTabsProps {
activeRole: LoginRole;
onRoleChange: (role: LoginRole) => void;
disabled?: boolean;
}
const ROLES: LoginRole[] = ['User', 'Agent'];
/** Each selectable role, with the feature that controls whether it is offered. */
const ROLE_OPTIONS: Array<{ id: LoginRole; feature: FeatureKey; label: string }> = [
{ id: 'User', feature: FeatureKey.AUTH_LOGIN_USER_ROLE, label: 'Customer' },
{ id: 'Agent', feature: FeatureKey.AUTH_LOGIN_AGENT_ROLE, label: 'Agent' },
];
const ActiveTabBackground = () => (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
layout={Layout.springify().damping(15)}
style={StyleSheet.absoluteFill}
>
<Svg width="100%" height="100%" viewBox="0 0 110 36" preserveAspectRatio="none">
<Path d="M 0,36 L 0,12 Q 0,0 12,0 L 80,0 Q 88,0 92,6 L 110,36 Z" fill="#143e6c" />
</Svg>
</Animated.View>
);
/**
* Chooses which audience is signing in.
*
* This was a pair of hand-drawn SVG tabs with an angled edge, overlapped by a
* negative margin and welded to the form card below by a squared-off corner —
* which meant the card could never change shape, and the tab fill was a
* hardcoded #143e6c that had drifted off the brand navy. It is a segmented
* control now: the same choice, in the shared component, on brand, and
* announced properly as a tab list.
*
* The roles are labelled "Customer" and "Agent" rather than by their internal
* `User` / `Agent` values — "User" is not a word a bank shows a customer.
*/
export const LoginRoleTabs = ({ activeRole, onRoleChange, disabled }: LoginRoleTabsProps) => {
const roles = useEnabledItems(ROLE_OPTIONS);
export const LoginRoleTabs: React.FC<LoginRoleTabsProps> = ({ activeRole, onRoleChange }) => {
const theme = useTheme();
const options = useMemo(
() => roles.map(({ id, label }) => ({ value: id, label })),
[roles],
);
// With only one role available the strip is meaningless, so it is not
// rendered at all — the user never learns another role existed.
if (roles.length < 2) {
return null;
}
return (
<View style={styles.container}>
{ROLES.map((role, index) => {
const isActive = activeRole === role;
return (
<Pressable
key={role}
onPress={() => {
if (!isActive) {
haptics.selection();
onRoleChange(role);
}
}}
style={({ pressed }) => [
styles.tab,
index > 0 && { marginLeft: isActive ? -10 : 0 },
isActive && { zIndex: 10 },
pressed && !isActive && { opacity: 0.6 },
pressed && isActive && { transform: [{ scale: 0.98 }] }
]}
>
{isActive && <ActiveTabBackground />}
<Animated.Text
layout={Layout.springify().damping(15)}
style={[
styles.tabText,
isActive ? styles.activeText : [styles.inactiveText, { color: theme.colors.textSecondary }],
]}
>
{role}
</Animated.Text>
</Pressable>
);
})}
</View>
<SegmentedControl
options={options}
value={activeRole}
onChange={onRoleChange}
disabled={disabled}
accessibilityLabel="Sign in as"
/>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: -1,
zIndex: 10,
marginLeft: 0,
},
tab: {
justifyContent: 'center',
alignItems: 'center',
height: 38,
width: 110,
},
tabText: {
fontSize: 14,
fontWeight: '700',
zIndex: 2,
transform: [{ translateX: -4 }],
},
activeText: {
color: '#FFFFFF',
},
inactiveText: {
fontWeight: '600',
},
});
@@ -0,0 +1,137 @@
import React, { useCallback, useMemo } from 'react';
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
import { appConfig } from '../../../config/appConfig';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
interface PreLoginAction {
id: string;
label: string;
icon: IoniconName;
onPress: () => void;
/** Omitted means always offered. */
feature?: FeatureKey;
}
interface PreLoginActionsProps {
onOpenAccount: () => void;
}
/**
* The row of public actions along the foot of the sign-in screen — the things
* someone can do without being able to get in.
*
* It scrolls horizontally and lays out whatever it is given rather than
* assuming a fixed count, because the actions are feature-gated: with
* registration switched off, "Open an account" is not offered and the row is
* one tile shorter.
*
* Only actions with a real destination are listed. An action that opens nothing
* is worse than an absent one on a bank's sign-in screen, so there is no
* placeholder here — a new tile arrives when the screen behind it does.
*/
export const PreLoginActions = ({ onOpenAccount }: PreLoginActionsProps) => {
const theme = useTheme();
const handleContact = useCallback(() => {
haptics.selection();
// The configured support address, not a hardcoded one, so it follows the
// environment the build points at.
void Linking.openURL(`mailto:${appConfig.supportEmail}`);
}, []);
const actions = useMemo<PreLoginAction[]>(
() => [
{
id: 'open-account',
label: 'Open an account',
icon: 'person-add-outline',
onPress: onOpenAccount,
// Gated by the same feature that registers the Register screen — an
// ungated tile here would navigate to a route that no longer exists.
feature: FeatureKey.AUTH_REGISTER,
},
{
id: 'contact',
label: 'Contact us',
icon: 'mail-outline',
onPress: handleContact,
},
],
[handleContact, onOpenAccount],
);
const visible = useEnabledItems(actions);
if (visible.length === 0) {
return null;
}
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{visible.map((action) => (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityLabel={action.label}
onPress={action.onPress}
style={({ pressed }) => [
styles.tile,
{
borderRadius: theme.radius.md,
backgroundColor: pressed
? theme.colors.onBrandSurfaceStrong
: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.onBrandPrimary} />
{/* Solid white, not `onBrandSecondary`. The tile's own translucent
fill lightens the wash underneath it, so a translucent label on
top of it lands at 4.37:1 at the top of the gradient — under AA.
Text on an on-brand fill is always the solid tone. */}
<Text
numberOfLines={2}
style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}
>
{action.label}
</Text>
</Pressable>
))}
{/* Trailing spacer so the last tile can clear the screen edge when the
row is long enough to scroll. */}
<View style={styles.spacer} />
</ScrollView>
);
};
const styles = StyleSheet.create({
row: {
gap: 10,
paddingHorizontal: 16,
},
tile: {
width: 88,
height: 88,
borderWidth: 1,
padding: 12,
// Icon pinned to the top, label to the bottom — the reference layout, and
// it keeps a two-line label from shoving the icon around.
justifyContent: 'space-between',
},
spacer: {
width: 6,
},
});
+379
View File
@@ -0,0 +1,379 @@
import React, { memo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type DimensionValue,
type LayoutChangeEvent,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Svg, { Path, Rect } from 'react-native-svg';
import { fontFamily, useTheme } from '../../../theme';
import type { SimSubscription } from '../../../services/device/simCard';
/**
* Lists the lines in this handset and sends the one-time code to whichever the
* customer taps.
*
* Each option is drawn as the SIM itself — the chamfered corner and the contact
* chip — because the question being asked is "which of the two cards in your
* tray is the one your bank has", and the answer lives on a physical object the
* customer can picture. A plain rounded rectangle with a label is a list item;
* this is a picture of the thing.
*
* Cards side by side rather than a column, because the two lines are peers
* being compared, and a stacked list reads top-down as though the first entry
* were the default. The row starts at the left margin, on the same edge as
* the heading and the hero copy above it. Each card is a tap that sends the OTP
* outright:
* registration has no number field for a choice to fill in, so there is no
* confirm step and no primary action to rank the cards against.
*
* A slot whose number the carrier never provisioned cannot be tapped, because
* there is nothing to send a code to. It keeps its card, dimmed and labelled:
* on a dual-SIM handset, silently dropping one line reads as a bug.
*/
type Props = {
sims: SimSubscription[];
/** `subscriptionId` whose code request is in flight, or null when idle. */
busyId: number | null;
onSelect: (sim: SimSubscription) => void;
/** Blocks every card — set while any request is in flight. */
disabled?: boolean;
/**
* Set when this sits directly on the navy brand wash rather than on a light
* surface. Only the heading changes colour: the cards stay light, because the
* number is the one thing on this screen a customer has to read without
* hesitating, and dark-on-light is the most legible way to carry it.
*/
onBrand?: boolean;
};
/** Corner radius of the card silhouette, in points. */
const RADIUS = 12;
/** How much of the top-right corner the chamfer cuts away. */
const CHAMFER = 24;
/**
* The SIM outline, drawn to measured pixel dimensions.
*
* Drawn rather than clipped: React Native will not clip a View to anything but
* a rounded rectangle, and faking the chamfer with a triangle in the background
* colour cannot work here — the card sits on a gradient, so a solid triangle
* would show a seam. As a filled path, everything outside the outline is simply
* transparent and the wash shows through correctly.
*
* It takes real width and height instead of scaling a square viewBox, because
* `preserveAspectRatio="none"` on a non-square card would stretch the corner
* radii and skew the chamfer off 45°.
*/
const SimSilhouette = ({
width,
height,
fill,
stroke,
strokeWidth = 1.5,
}: {
width: number;
height: number;
fill: string;
stroke: string;
strokeWidth?: number;
}) => {
// Inset by half the stroke so the outline sits fully inside the layout box
// rather than being clipped in half along the edges.
const i = strokeWidth / 2;
const w = width - i;
const h = height - i;
// Clamped so the cut can never eat into the rounded corners and invert the
// path. A card this narrow should not happen at 46.5% of any real handset,
// but it can under a 130% system font scale or in split-screen, and a
// self-crossing path renders as a torn shape rather than as nothing.
const chamfer = Math.max(0, Math.min(CHAMFER, w - i - RADIUS * 2, h - i - RADIUS * 2));
const d = [
`M ${i + RADIUS},${i}`,
`L ${w - chamfer},${i}`,
// The cut corner. This one line is what makes the shape a SIM.
`L ${w},${i + chamfer}`,
`L ${w},${h - RADIUS}`,
`Q ${w},${h} ${w - RADIUS},${h}`,
`L ${i + RADIUS},${h}`,
`Q ${i},${h} ${i},${h - RADIUS}`,
`L ${i},${i + RADIUS}`,
`Q ${i},${i} ${i + RADIUS},${i}`,
'Z',
].join(' ');
return (
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Path d={d} fill={fill} stroke={stroke} strokeWidth={strokeWidth} strokeLinejoin="round" />
</Svg>
);
};
/**
* The gold contact pad. The chamfer says "SIM" from across the room; the chip is
* what makes it unmistakable up close.
*/
const ContactChip = ({ color, tint }: { color: string; tint: string }) => (
<Svg width={26} height={20} viewBox="0 0 26 20">
<Rect
x={0.7}
y={0.7}
width={24.6}
height={18.6}
rx={3}
fill={tint}
stroke={color}
strokeWidth={1.4}
/>
{/* Two rules across and one down: six pads, which is the pattern on the
cards people actually hold. */}
<Path
d="M0.7,7 H25.3 M0.7,13 H25.3 M13,0.7 V19.3"
fill="none"
stroke={color}
strokeWidth={1}
/>
</Svg>
);
/** What to call a slot: the customer's own label first, then the carrier. */
export const simTitle = (sim: SimSubscription) =>
sim.displayName ?? sim.carrierName ?? `SIM ${sim.slotIndex + 1}`;
const formatNumber = (digits: string) => digits.replace(/(\d{5})(\d{5})/, '$1 $2');
const SimCard = ({
sim,
busy,
blocked,
onPress,
width,
}: {
sim: SimSubscription;
busy: boolean;
blocked: boolean;
onPress: () => void;
width: DimensionValue;
}) => {
const theme = useTheme();
const [box, setBox] = useState({ width: 0, height: 0 });
const [pressed, setPressed] = useState(false);
const number = sim.phoneNumber;
const title = simTitle(sim);
const accent = number ? theme.colors.primary : theme.colors.textMuted;
const handleLayout = (event: LayoutChangeEvent) => {
const { width: w, height: h } = event.nativeEvent.layout;
// Only re-render on a real change; onLayout fires on every parent pass.
setBox((current) =>
Math.abs(current.width - w) < 0.5 && Math.abs(current.height - h) < 0.5
? current
: { width: w, height: h },
);
};
const fill = !number
? theme.colors.surfaceMuted
: pressed
? theme.colors.primarySurface
: theme.colors.surface;
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onLayout={handleLayout}
disabled={blocked}
accessibilityRole="button"
accessibilityState={{ disabled: blocked, busy }}
accessibilityLabel={
number
? `Send a code to ${title}, ${formatNumber(number)}`
: `${title} — your carrier does not share this number`
}
// No borderRadius, no border and no elevation on the view itself: the
// silhouette below carries all three. React Native's shadow and Android's
// elevation both follow the view's rectangular outline, so a lift here
// would draw a square shadow behind a chamfered card.
style={[styles.card, { width }, !number && styles.cardUnusable]}
>
{box.width > 0 ? (
<SimSilhouette
width={box.width}
height={box.height}
fill={fill}
stroke={number ? theme.colors.primary : theme.colors.cardBorder}
/>
) : null}
<View style={styles.content}>
<View style={styles.chipRow}>
<ContactChip
color={accent}
tint={number ? theme.colors.primarySurface : theme.colors.surfaceSunk}
/>
<Text style={[theme.text.eyebrow, styles.slotLabel, { color: accent }]}>
{`SIM ${sim.slotIndex + 1}`}
</Text>
</View>
{/* The number is the card's subject, so it carries the weight. Tabular
figures keep the two cards' digits the same width, so a dual-SIM
pair lines up instead of drifting. */}
<Text
numberOfLines={1}
style={[
styles.number,
{ color: number ? theme.colors.textPrimary : theme.colors.textMuted },
]}
>
{number ? formatNumber(number) : 'Not shared'}
</Text>
<Text
numberOfLines={1}
style={[theme.text.caption, { color: theme.colors.textSecondary }]}
>
{title}
</Text>
{/* No wording here any more. "Get code" restated the heading directly
above it — which already says to tap the SIM — on both cards at
once, and the card itself is the button. The row stays because a tap
fires an OTP request and that needs somewhere visible to report
itself. */}
<View style={styles.action}>
{busy ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Ionicons
name={number ? 'arrow-forward' : 'close-circle-outline'}
size={18}
color={accent}
/>
)}
</View>
</View>
</Pressable>
);
};
const SimChooserComponent = ({
sims,
busyId,
onSelect,
disabled = false,
onBrand = false,
}: Props) => {
const theme = useTheme();
// A lone card at full width stops reading as a card and starts reading as a
// banner, so a single SIM keeps roughly the footprint it would have had as
// one of a pair, sitting on the left margin rather than stretching.
// Kept under half each so two cards plus the 12pt gap can never overflow the
// row and wrap the second card onto a line of its own.
const cardWidth = sims.length === 1 ? '56%' : '46.5%';
return (
<View style={styles.wrapper}>
<Text
style={[
theme.text.eyebrow,
{ color: onBrand ? theme.colors.onBrandSecondary : theme.colors.textSecondary },
]}
>
{sims.length > 1 ? 'Tap the SIM registered with your bank' : 'Tap your SIM to get a code'}
</Text>
<View style={styles.grid}>
{sims.map((sim) => (
<SimCard
key={sim.subscriptionId}
sim={sim}
busy={sim.subscriptionId === busyId}
blocked={disabled || !sim.phoneNumber}
onPress={() => onSelect(sim)}
width={cardWidth}
/>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: { gap: 12 },
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
// Left-aligned, not centred: the cards share the left margin with the
// heading directly above them and with the hero copy above that. Centred, a
// lone card floated off that edge and read as an unrelated block dropped
// onto the screen.
justifyContent: 'flex-start',
gap: 12,
},
card: {
// Slightly wider than tall, the way a SIM sits in the tray. Not the true
// 1.4:1 of a nano-SIM: four stacked rows do not fit in the 105pt height
// that would give on a 360pt handset, and an unreadable number is a worse
// outcome than an approximate proportion.
minHeight: 134,
},
cardUnusable: { opacity: 0.7 },
content: {
flex: 1,
// Left-aligned, because the chip sits in a corner on a real SIM and
// centring the rows fights that asymmetry.
alignItems: 'flex-start',
justifyContent: 'center',
gap: 5,
paddingVertical: 13,
paddingHorizontal: 13,
},
chipRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 3,
// The cut corner is at the TOP right, so only this row has to keep clear of
// it. Insetting the whole card would have cost the number 24pt of width it
// needs — at 16pt ExtraBold, "81202 86862" wants nearly all of it.
paddingRight: CHAMFER / 2,
},
slotLabel: {
fontSize: 9,
lineHeight: 12,
letterSpacing: 0.6,
},
number: {
// Mono, like every other figure in the app. At 0.6em per character an
// 11-character number needs ~106pt, and the card's content box is 121pt.
// `fontVariant: ['tabular-nums']` is gone with it: monospace is tabular by
// construction, so the feature flag was asking for something it already had.
fontFamily: fontFamily.monoSemibold,
fontSize: 16,
lineHeight: 22,
letterSpacing: -0.2,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 3,
minHeight: 20,
},
});
export const SimChooser = memo(SimChooserComponent);
+9 -12
View File
@@ -1,13 +1,15 @@
import { useCallback } from 'react';
import { useAppDispatch } from '../../../app/hooks';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
export const useBiometricPrompt = () => {
const dispatch = useAppDispatch();
return useCallback(async () => {
/**
* Raises the device's own biometric prompt. Live sign-in does not go through
* this — reading the stored MPIN out of the Keystore raises the platform prompt
* itself, so asking twice would be theatre. This is for the local agent session
* and for confirming an action inside the app.
*/
export const useBiometricPrompt = () =>
useCallback(async () => {
const available = await biometrics.isAvailable();
if (!available) {
@@ -16,10 +18,5 @@ export const useBiometricPrompt = () => {
const result = await biometrics.prompt();
if (result.success) {
dispatch(authActions.setAuthMethod('biometric'));
}
return result.success;
}, [dispatch]);
};
}, []);
@@ -0,0 +1,245 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
import { selectRegistration } from '../store/authSelectors';
import { completeMpinSetup, rememberRegisteredProfile } from '../store/authThunk';
import { MPIN_LENGTH, describeMpinProblem, isValidMpin } from '../utils/authValidation';
import { CodeInput } from '../components/CodeInput';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'CreateMpin'>>;
export default function CreateMpinScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const [mpin, setMpin] = useState('');
const [confirmMpin, setConfirmMpin] = useState('');
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
const [submitted, setSubmitted] = useState(false);
const isLoading = registration.status === 'loading';
useEffect(() => {
void biometrics.isAvailable().then(setBiometricsAvailable);
}, []);
// A setup token this screen never received cannot be recovered from here —
// the five-minute window has to be reopened from the start.
useEffect(() => {
if (!registration.setupToken && !isLoading && !submitted) {
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
}
}, [dispatch, isLoading, navigation, registration.setupToken, submitted]);
useEffect(() => {
if (!submitted || registration.status !== 'failed' || !registration.error) {
return;
}
setSubmitted(false);
showApiError(registration.error);
const { code } = registration.error;
if (code === 'OTP_NOT_VERIFIED') {
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
return;
}
if (code === 'MPIN_ALREADY_SET') {
// verify-otp said this profile still owed an MPIN and the server now says
// otherwise, so the profile has to be recorded here — nothing earlier in
// this path stored one, and sign-in would read that as a device that has
// never registered and send them back for another OTP.
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
navigation.navigate('Login');
return;
}
setMpin('');
setConfirmMpin('');
}, [
dispatch,
navigation,
registration.error,
registration.mobileNumber,
registration.orgId,
registration.status,
showApiError,
submitted,
]);
const mpinProblem = describeMpinProblem(mpin);
const confirmProblem =
confirmMpin.length === MPIN_LENGTH && confirmMpin !== mpin
? 'Both entries must match'
: undefined;
const canSubmit =
isValidMpin(mpin) && confirmMpin === mpin && !isLoading;
const handleSubmit = () => {
if (!canSubmit) {
return;
}
setSubmitted(true);
void dispatch(
completeMpinSetup({
mpin,
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
}),
);
};
return (
<ScreenShell
title="Create your MPIN"
subtitle="You will use this to sign in from now on"
withTabBar={false}
contentContainerStyle={styles.container}
>
<Card style={styles.formCard}>
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
{`CHOOSE A ${MPIN_LENGTH}-DIGIT MPIN`}
</Text>
<CodeInput
value={mpin}
onChangeText={setMpin}
length={MPIN_LENGTH}
secure
autoFocus
editable={!isLoading}
error={mpinProblem}
/>
<CodeInput
label="Confirm MPIN"
value={confirmMpin}
onChangeText={setConfirmMpin}
length={MPIN_LENGTH}
secure
editable={!isLoading}
error={confirmProblem}
onSubmit={handleSubmit}
/>
</Card>
{biometricsAvailable && (
<Card style={styles.biometricCard}>
<View style={styles.biometricRow}>
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
<View style={styles.biometricCopy}>
<Text style={[styles.biometricTitle, { color: theme.colors.textPrimary }]}>
Sign in with biometrics
</Text>
{/* Worth being straight about: the API opens a session only
against the MPIN, so enabling this keeps the MPIN on the
device behind the system prompt. */}
<Text style={[styles.biometricNote, { color: theme.colors.textSecondary }]}>
Stores your MPIN in this device&apos;s secure hardware, unlocked by your
fingerprint or face.
</Text>
</View>
<Switch
value={rememberForBiometrics}
onValueChange={setRememberForBiometrics}
disabled={isLoading}
/>
</View>
</Card>
)}
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
Three wrong MPINs lock your profile, and only your branch can unlock it. Pick
something you will remember.
</Text>
</View>
<Button
title="Set MPIN and continue"
onPress={handleSubmit}
disabled={!canSubmit}
loading={isLoading}
style={styles.submitButton}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
flexGrow: 1,
},
formCard: {
padding: 16,
gap: 16,
},
sectionLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
marginLeft: 4,
},
biometricCard: {
padding: 16,
},
biometricRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
biometricCopy: {
flex: 1,
gap: 2,
},
biometricTitle: {
fontSize: 15,
fontFamily: fontFamily.bold,
},
biometricNote: {
fontSize: 12,
lineHeight: 17,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
padding: 14,
borderRadius: 12,
},
noteText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
},
submitButton: {
marginTop: 'auto',
height: 54,
},
});
+566 -183
View File
@@ -1,169 +1,566 @@
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StatusBar,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import LinearGradient from 'react-native-linear-gradient';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { Button, InfoBanner, Input, Loader } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useToast } from '../../../hooks/useToast';
import { secureStorage } from '../../../services/storage/secureStorage';
import { authActions } from '../store/authSlice';
import { useLoginMutation } from '../services/authApi';
import { isValidPhone } from '../utils/authValidation';
import { LoginHero } from '../components/LoginHero';
import { NeuralGrid } from '../components/NeuralGrid';
import { BiometricNode } from '../components/BiometricNode';
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
import { haptics } from '../../../services/haptics/haptics';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
import {
selectBiometricReady,
selectLastProfile,
selectMpinLocked,
selectSignInContextLoaded,
selectSignInError,
selectSignInStatus,
} from '../store/authSelectors';
import {
loadSignInContext,
signIn,
signInAsAgent,
signInWithBiometrics,
} from '../store/authThunk';
import { MPIN_LENGTH, isValidMpin, isValidPhone } from '../utils/authValidation';
import { LoginHero } from '../components/LoginHero';
import { BiometricNode } from '../components/BiometricNode';
import { CodeInput } from '../components/CodeInput';
import { PreLoginActions } from '../components/PreLoginActions';
import { LoginFooter } from '../components/LoginFooter';
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
import type { AuthStackParamList } from '../../../navigation/types';
import type { LoginRole } from '../../../types/app';
import { LoginRoleTabs } from '../components/LoginRoleTabs';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate, useFeature } from '../../../features';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
export default function LoginScreen(props: Readonly<Props>) {
export default function LoginScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const showApiError = useApiErrorToast();
const promptBiometric = useBiometricPrompt();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const lastProfile = useAppSelector(selectLastProfile);
const contextLoaded = useAppSelector(selectSignInContextLoaded);
const biometricReady = useAppSelector(selectBiometricReady);
const signInStatus = useAppSelector(selectSignInStatus);
const signInError = useAppSelector(selectSignInError);
const mpinLocked = useAppSelector(selectMpinLocked);
const [mobile, setMobile] = useState('');
const [mpin, setMpin] = useState('');
const [activeRole, setActiveRole] = useState<LoginRole>('User');
const [showPassword, setShowPassword] = useState(false);
const [login, loginState] = useLoginMutation();
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
/** Set once the customer chooses the MPIN path over the biometric one. */
const [mpinRequested, setMpinRequested] = useState(false);
const [keyboardUp, setKeyboardUp] = useState(false);
/**
* The last failure, held for display on the form itself. The toast that
* `showApiError` raises is gone in a few seconds, and the effect below clears
* the MPIN boxes at the same time — so without this, a wrong MPIN looks like
* the app simply did nothing.
*/
const [formError, setFormError] = useState<string | undefined>();
const handleForgotPassword = () => {
haptics.selection();
toast('Resetting your password', 'info', 'We are taking you to reset your password.');
};
const handleSignUp = () => {
haptics.selection();
toast('Creating your account', 'info', 'We are taking you to open a new account.');
};
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
const handleContinue = async () => {
try {
const tokens = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Signed in', 'success', 'Welcome back to your account.');
return;
} catch {
haptics.error();
toast('Sign-in failed', 'error', 'We could not verify your details. Please check and try again.');
const isLoading = signInStatus === 'loading';
/**
* A profile stored on this device means registration already completed here,
* so the customer only owes us an MPIN — the number is not theirs to retype.
* The agent app has no stored profile, so it keeps the full form.
*/
const isReturningCustomer = activeRole === 'User' && Boolean(lastProfile);
useEffect(() => {
void biometrics.isAvailable().then(setBiometricsAvailable);
}, []);
// The public action row is pinned to the foot of the screen, which is exactly
// where the keyboard lands. Hiding it while the keyboard is up keeps it from
// being squeezed into a sliver above the keys.
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const shown = Keyboard.addListener(showEvent, () => setKeyboardUp(true));
const hidden = Keyboard.addListener(hideEvent, () => setKeyboardUp(false));
return () => {
shown.remove();
hidden.remove();
};
}, []);
// Several routes reach this screen after the store was wiped — an ordinary
// logout, or a stored session found dead at launch. Re-reading the remembered
// profile here covers all of them rather than one at a time.
useEffect(() => {
if (!contextLoaded) {
void dispatch(loadSignInContext());
}
// Mount-only: once loaded, re-running would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);
// First launch is routed straight to registration by AuthNavigator, so this
// only catches the late case: a logout wiped the store and the re-read found
// no profile (app data cleared, or the device was unbound). By then the
// navigator is long since ready, so the action is safe to dispatch here.
useEffect(() => {
if (contextLoaded && !lastProfile && activeRole === 'User') {
navigation.replace('Register');
}
}, [activeRole, contextLoaded, lastProfile, navigation]);
useEffect(() => {
if (lastProfile) {
setMobile((current) => current || lastProfile.mobileNumber);
}
}, [lastProfile]);
// Keep the selected role on something that is actually offered — otherwise a
// disabled role could still be submitted via the default state.
useEffect(() => {
if (activeRole === 'User' && !userRoleEnabled && agentRoleEnabled) {
setActiveRole('Agent');
} else if (activeRole === 'Agent' && !agentRoleEnabled && userRoleEnabled) {
setActiveRole('User');
}
}, [activeRole, agentRoleEnabled, userRoleEnabled]);
// A locked profile has no in-app path at all, so it gets its own screen
// rather than a toast the user can dismiss and keep hammering the form.
useEffect(() => {
if (mpinLocked) {
navigation.navigate('MpinLocked');
}
}, [mpinLocked, navigation]);
useEffect(() => {
if (signInStatus !== 'failed' || !signInError) {
return;
}
// MPIN_LOCKED gets the dedicated screen above; a cancelled biometric
// prompt is a user decision, not a failure worth shouting about.
if (signInError.code === 'MPIN_LOCKED') {
return;
}
if (signInError.code === 'BIOMETRIC_CANCELLED') {
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
dispatch(authActions.clearSignInError());
return;
}
showApiError(signInError);
// A device that was never bound, or a profile with no MPIN yet, cannot sign
// in whatever the MPIN — registration is the only way forward.
if (signInError.code === 'DEVICE_NOT_BOUND' || signInError.code === 'MPIN_NOT_SET') {
navigation.navigate('Register');
} else {
// Every other failure leaves the customer on this screen, so the reason
// has to stay on this screen with them — and the MPIN field comes out,
// since a biometric failure needs a way through that is not biometrics.
setFormError(signInError.message);
setMpinRequested(true);
}
setMpin('');
dispatch(authActions.clearSignInError());
}, [dispatch, navigation, showApiError, signInError, signInStatus, toast]);
const mobileError = useMemo(() => {
if (mobile.length === 0 || isValidPhone(mobile)) {
return undefined;
}
return 'Enter the 10-digit number registered with your bank';
}, [mobile]);
const canSubmit = isValidPhone(mobile) && isValidMpin(mpin) && !isLoading;
// Only worth offering once a profile on this device has actually stored an
// MPIN behind the device prompt — otherwise the button can only ever fail.
const canUseBiometrics = activeRole === 'Agent' || biometricReady;
const biometricsGranted = useFeature(FeatureKey.AUTH_LOGIN_BIOMETRIC);
/**
* Biometrics lead only for a returning customer who has them ready: the
* device knows who they are, so one tap is the whole sign-in. Everyone else —
* an agent, or a customer with nothing enrolled — needs the form anyway, and
* hiding it behind a button would just add a tap to the only way in.
*/
const biometricsLead = canUseBiometrics && biometricsGranted && isReturningCustomer;
const formVisible = !biometricsLead || mpinRequested;
// Switching role changes which of the two paths applies, so the disclosure
// resets with it rather than leaving the form open from the previous role.
useEffect(() => {
setMpinRequested(false);
setFormError(undefined);
setMpin('');
}, [activeRole]);
const handleMobileChange = (value: string) => {
setFormError(undefined);
setMobile(value.replace(/\D/g, ''));
};
const handleMpinChange = (value: string) => {
// Retyping is the customer acting on the error, so the notice goes away as
// soon as they start — leaving it up would read as a second failure.
setFormError(undefined);
setMpin(value);
};
const handleSignIn = () => {
if (!canSubmit) {
return;
}
if (activeRole === 'Agent') {
// The field-agent app has no CBS backend behind it yet.
void dispatch(signInAsAgent({ mobileNumber: mobile }));
return;
}
void dispatch(
signIn({
mobileNumber: mobile,
mpin,
orgId: lastProfile?.orgId,
// This sign-in is the only place the MPIN is verified, so it is the
// only safe moment to store it for biometric use.
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
}),
);
};
const handleBiometricLogin = async () => {
haptics.selection();
const success = await promptBiometric();
if (!success) {
toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.');
if (activeRole === 'Agent') {
const confirmed = await promptBiometric();
if (!confirmed) {
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
return;
}
void dispatch(signInAsAgent({ mobileNumber: mobile || '0000000000' }));
return;
}
const tokens = {
accessToken: 'biometric-access-token',
refreshToken: 'biometric-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.');
// Reading the stored MPIN raises the platform's own prompt, so there is no
// separate biometric check to run first.
void dispatch(signInWithBiometrics());
};
const handleRegisterAgain = () => {
haptics.selection();
dispatch(authActions.resetRegistration());
navigation.navigate('Register');
};
const handleUseMpin = () => {
haptics.selection();
setMpinRequested(true);
};
/**
* Indian mobile grouping is 5 + 5. Masking the first six digits and grouping
* what is left the same way keeps the number recognisable in the shape the
* customer knows it in, without printing it in full on a locked screen.
*/
const maskedMobile = lastProfile?.mobileNumber
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
: undefined;
const gradient = (
<>
<LinearGradient
colors={theme.colors.brandGradient}
style={StyleSheet.absoluteFill}
// A vertical wash. Two of the three stops carry the brand navy itself,
// so the screen still reads as one colour rather than as a fade.
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* The wash is dark, so the status bar icons have to be light — the
opposite of every other screen in the app. */}
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
</>
);
// Deciding between the MPIN screen and registration needs the stored profile.
// Rendering either one first would flash the wrong screen.
if (!contextLoaded) {
return (
<View style={styles.flex}>
{gradient}
<SafeAreaView edges={['top']} style={[styles.flex, styles.centre]}>
<Loader />
</SafeAreaView>
</View>
);
}
return (
<View style={styles.flex}>
<ScreenShell scrollable={true} contentContainerStyle={styles.shellContainer}>
<View style={styles.content}>
<View style={styles.heroWrapper}>
<LoginHero />
</View>
{gradient}
<Animated.View entering={FadeInUp.delay(400).duration(800).springify()}>
<LoginRoleTabs activeRole={activeRole} onRoleChange={setActiveRole} />
<Card
style={[
styles.formCard,
activeRole === 'User' ? { borderTopLeftRadius: 0 } : {}
]}
>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>EMAIL OR PHONE</Text>
<Input
keyboardType="default"
value={phone}
onChangeText={setPhone}
placeholder="name@example.com"
/>
</View>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>PASSWORD</Text>
<Input
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
rightAccessory={
<Pressable onPress={() => setShowPassword(!showPassword)} hitSlop={12}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.textMuted}
/>
</Pressable>
}
/>
</View>
<View style={styles.forgotPasswordRow}>
<Pressable onPress={handleForgotPassword} hitSlop={12}>
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Forgot password?</Text>
</Pressable>
</View>
<Button
title="Sign In"
loading={loginState.isLoading}
onPress={handleContinue}
style={styles.mainButton}
/>
<View style={styles.dividerRow}>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.dividerText, { color: theme.colors.textMuted }]}>OR SIGN IN WITH</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
</View>
<BiometricNode onPress={handleBiometricLogin} />
</Card>
</Animated.View>
<Animated.View
entering={FadeInDown.delay(600).duration(800)}
style={styles.bottomLinks}
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<Text style={[styles.signUpText, { color: theme.colors.textSecondary }]}>
New to BankCBS?{' '}
<Text style={[styles.signUpLink, { color: theme.colors.primary }]} onPress={handleSignUp}>Create account</Text>
</Text>
</Animated.View>
</View>
</ScreenShell>
{/* ── brand lock-up ─────────────────────────────────────────── */}
<LoginHero />
{/* ── who is signing in ─────────────────────────────────────── */}
<LoginRoleTabs
activeRole={activeRole}
onRoleChange={setActiveRole}
disabled={isLoading}
/>
{/* ── greeting ──────────────────────────────────────────────── */}
<View style={styles.greeting}>
{isReturningCustomer && lastProfile?.fullName ? (
<>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
Welcome back
</Text>
<Text
numberOfLines={2}
style={[theme.text.h1, styles.centreText, { color: theme.colors.onBrandPrimary }]}
>
{lastProfile.fullName}
</Text>
{maskedMobile ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
) : (
<>
<Text
style={[
theme.text.h1,
styles.centreText,
{ color: theme.colors.onBrandPrimary },
]}
>
{activeRole === 'Agent' ? 'Agent sign-in' : 'Welcome'}
</Text>
{/*
A returning customer whose name we do not hold yet still
gets told which profile they are signing into. That is the
device-rebind path: verify-otp stores the mobile number but
no name, because it does not return one — so without this
the screen asks for an MPIN and names nothing at all.
*/}
{maskedMobile ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
)}
</View>
{/* ── the way in ────────────────────────────────────────────── */}
{biometricsLead ? (
<Animated.View entering={FadeIn.duration(320)} style={styles.heroBlock}>
<BiometricNode
variant="hero"
onPress={handleBiometricLogin}
disabled={isLoading}
caption="Tap to sign in"
/>
</Animated.View>
) : null}
{formVisible ? (
<Animated.View entering={FadeInUp.duration(280)} style={styles.form}>
{!isReturningCustomer ? (
<Input
label="Registered mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={mobile}
onChangeText={handleMobileChange}
placeholder="98765 43210"
error={mobileError}
editable={!isLoading}
/>
) : null}
<CodeInput
label="Enter MPIN"
value={mpin}
onChangeText={handleMpinChange}
length={MPIN_LENGTH}
secure
autoFocus={mpinRequested || isReturningCustomer}
editable={!isLoading}
onSubmit={handleSignIn}
/>
{formError ? <InfoBanner tone="danger" message={formError} /> : null}
{/* Offered only where it can actually be turned on: biometrics
enrolled, a customer sign-in, and nothing stored yet. */}
{activeRole === 'User' && biometricsAvailable && !biometricReady ? (
<View style={styles.rememberRow}>
<Text
style={[
theme.text.bodySmall,
styles.rememberLabel,
{ color: theme.colors.onBrandSecondary },
]}
>
Enable biometric sign-in next time
</Text>
<Switch
value={rememberForBiometrics}
onValueChange={setRememberForBiometrics}
disabled={isLoading}
accessibilityLabel="Enable biometric sign-in next time"
// The thumb stays white in both states. A navy thumb on
// the translucent-white track composites to roughly
// 1.7:1 against it and effectively disappears on the
// navy wash.
trackColor={{
false: theme.colors.onBrandSurfaceStrong,
true: theme.colors.success,
}}
thumbColor={theme.colors.onBrandPrimary}
/>
</View>
) : null}
{/* White fill, navy label — `primary` here would be navy on
navy and disappear into the wash. */}
<Button
title="Sign in"
variant="inverse"
loading={isLoading}
disabled={!canSubmit}
onPress={handleSignIn}
/>
{/* An agent, and a customer whose biometrics are enrolled but
not yet bound to a stored MPIN, never see the hero target
above — so their biometric path lives here instead, or they
would have none at all. */}
{canUseBiometrics && biometricsGranted && !biometricsLead ? (
<>
<View style={styles.dividerRow}>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
<Text style={[theme.text.eyebrow, { color: theme.colors.onBrandMuted }]}>
or sign in with
</Text>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
</View>
<BiometricNode onPress={handleBiometricLogin} disabled={isLoading} />
</>
) : null}
</Animated.View>
) : (
<Button
title="Sign in with MPIN"
variant="onBrand"
onPress={handleUseMpin}
disabled={isLoading}
rightAccessory={
<Ionicons
name="chevron-forward"
size={16}
color={theme.colors.onBrandPrimary}
/>
}
style={styles.mpinSwitch}
/>
)}
{/* ── footnotes ─────────────────────────────────────────────── */}
<View style={styles.footnotes}>
<FeatureGate feature={FeatureKey.AUTH_REGISTER}>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
{isReturningCustomer ? 'Not you? ' : 'New to BankCBS? '}
<Text
accessibilityRole="link"
style={{ color: theme.colors.onBrandPrimary }}
onPress={handleRegisterAgain}
>
Register
</Text>
</Text>
</FeatureGate>
{/* There is no forgot-MPIN or reset flow in the API — by policy,
not by omission — so the app says so instead of offering a
dead link. */}
<Text
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
>
Forgot your MPIN? Visit your branch to have it reset.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/*
Both of these are hidden while the keyboard is up. They are the
least urgent things on the screen, and the MPIN field needs the
height more than a legal line does.
*/}
{!keyboardUp ? (
<View style={styles.actions}>
<PreLoginActions onOpenAccount={handleRegisterAgain} />
<LoginFooter />
</View>
) : null}
</SafeAreaView>
</View>
);
}
@@ -172,77 +569,63 @@ const styles = StyleSheet.create({
flex: {
flex: 1,
},
shellContainer: {
flexGrow: 1,
paddingHorizontal: 20,
},
content: {
flex: 1,
centre: {
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 40,
},
heroWrapper: {
marginBottom: 8,
centreText: {
textAlign: 'center',
},
formCard: {
gap: 20,
padding: 24,
borderRadius: 24,
backgroundColor: '#FFFFFF',
// We'll rely on the Card component's internal variant/shadow or override
scroll: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 26,
paddingHorizontal: 24,
paddingVertical: 28,
},
inputGap: {
gap: 8,
greeting: {
alignItems: 'center',
gap: 4,
},
inputLabel: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
heroBlock: {
alignItems: 'center',
paddingVertical: 4,
},
forgotPasswordRow: {
form: {
width: '100%',
gap: 16,
},
mpinSwitch: {
// Narrower than the full gutter width, so the pill reads as one option
// among several rather than as the screen's committed action.
width: '88%',
},
rememberRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: -8,
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
linkText: {
fontSize: 14,
fontWeight: '600',
},
mainButton: {
height: 54,
borderRadius: 12,
rememberLabel: {
flex: 1,
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
marginVertical: 4,
},
divider: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
},
bottomLinks: {
gap: 16,
footnotes: {
alignItems: 'center',
marginTop: 32,
gap: 8,
},
signUpText: {
fontSize: 15,
fontWeight: '500',
},
signUpLink: {
fontWeight: '700',
actions: {
gap: 14,
paddingBottom: 8,
},
});
@@ -0,0 +1,97 @@
import React from 'react';
import { Linking, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { appConfig } from '../../../config/appConfig';
import { authActions } from '../store/authSlice';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'MpinLocked'>>;
/**
* A locked profile is a dead end by policy: there is no forgot-MPIN flow and no
* in-app reset anywhere in the API. Saying so plainly beats a retry button that
* can only ever fail.
*/
export default function MpinLockedScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const handleBackToSignIn = () => {
dispatch(authActions.dismissMpinLock());
navigation.navigate('Login');
};
const handleContactSupport = () => {
void Linking.openURL(`mailto:${appConfig.supportEmail}?subject=MPIN%20locked`);
};
return (
<ScreenShell
title="MPIN locked"
subtitle="Too many incorrect attempts"
withTabBar={false}
contentContainerStyle={styles.container}
>
<View style={[styles.iconWrap, { backgroundColor: `${theme.colors.error}14` }]}>
<Ionicons name="lock-closed-outline" size={40} color={theme.colors.error} />
</View>
<Card style={styles.card}>
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>
Visit your branch to unlock
</Text>
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
For your security, your profile locks after three incorrect MPIN entries. There is no
way to unlock it from the app — a branch officer has to reset it in person.
</Text>
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
Take a valid photo ID with you. Once your MPIN is reset you can sign in here as usual.
</Text>
</Card>
<View style={styles.actions}>
<Button title="Contact support" variant="secondary" onPress={handleContactSupport} />
<Button title="Back to sign in" variant="ghost" onPress={handleBackToSignIn} />
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 20,
flexGrow: 1,
alignItems: 'stretch',
},
iconWrap: {
alignSelf: 'center',
width: 88,
height: 88,
borderRadius: 44,
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
},
card: {
padding: 20,
gap: 12,
},
heading: {
fontSize: 18,
fontFamily: fontFamily.bold,
},
body: {
fontSize: 14,
lineHeight: 20,
},
actions: {
marginTop: 'auto',
gap: 12,
},
});
+733
View File
@@ -0,0 +1,733 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
AppState,
KeyboardAvoidingView,
Linking,
Platform,
Pressable,
ScrollView,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import LinearGradient from 'react-native-linear-gradient';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
import { Button, InfoBanner, Input } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { isValidPhone } from '../../../utils/validation';
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
import { SimChooser } from '../components/SimChooser';
import { authActions } from '../store/authSlice';
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
import { rememberRegisteredProfile, startRegistration } from '../store/authThunk';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Register'>>;
/**
* Three short marks of what this flow is, in the shape a bank's onboarding
* screen usually carries them.
*
* Each one is literally true of this app, which is the only reason it is
* allowed on the screen: the MPIN really is RSA-OAEP encrypted before it leaves
* the handset, the number really is read off the SIM when the phone will share
* it, and the profile really is bound to this device. Nothing here claims a
* certification, an audit, or a regulator — those would be invented, and an
* invented security claim on a bank's sign-up screen is a straightforward lie
* to a customer.
*/
const TRUST_MARKS = [
{ icon: 'shield-checkmark' as const, label: 'Secure' },
{ icon: 'flash' as const, label: 'Seamless' },
{ icon: 'phone-portrait' as const, label: 'Trusted device' },
];
export default function RegisterScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const lastProfile = useAppSelector(selectLastProfile);
const [sim, setSim] = useState<SimInfo | null>(null);
/** Which SIM's code request is in flight, so only that row spins. */
const [sendingId, setSendingId] = useState<number | null>(null);
const [detecting, setDetecting] = useState(true);
const [submitted, setSubmitted] = useState(false);
/** Bumped to re-read the SIMs. Never prompts — see the mount effect. */
const [attempt, setAttempt] = useState(0);
/**
* Android has stopped showing the permission dialog for this app. Asking
* again would return instantly and change nothing, so the only honest next
* step is app settings.
*/
const [permissionBlocked, setPermissionBlocked] = useState(false);
/**
* The typed-number fallback.
*
* Reading the SIM covers the common case, but a carrier that never wrote the
* MSISDN onto the card, a denied permission and a phone with no SIM in it
* each ended the flow outright — the screen said to contact the bank,
* because there was nowhere to put a number. It can be typed now, so none of
* those three is a dead end.
*/
const [manualOpen, setManualOpen] = useState(false);
const [manualNumber, setManualNumber] = useState('');
const isLoading = registration.status === 'loading';
const sims = sim?.sims ?? [];
// A line whose number the carrier never provisioned cannot be tapped — there
// is nothing to send from it. Typing the number is the way past that.
const tappable = sims.filter((entry) => entry.phoneNumber);
/**
* Reads on mount, and never prompts here.
*
* This used to call a `detect()` that raised the system permission dialog the
* instant the screen appeared — before the customer had read a word of what
* the screen was for, which is precisely how an app trains people to refuse.
* The prompt now belongs to a button the customer presses knowing what it
* will do.
*/
useEffect(() => {
let cancelled = false;
setDetecting(true);
void simCard.read().then((info) => {
if (cancelled) {
return;
}
setSim(info);
setDetecting(false);
});
return () => {
cancelled = true;
};
}, [attempt]);
// Coming back from app settings is the one case where the answer changes
// while this screen is mounted but nothing here was touched. Re-reading on
// foreground is what makes the settings round-trip actually finish.
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
setAttempt((value) => value + 1);
}
});
return () => subscription.remove();
}, []);
useEffect(() => {
if (!submitted) {
return;
}
if (registration.status === 'succeeded' && registration.otpSent) {
setSubmitted(false);
setSendingId(null);
navigation.navigate('VerifyOtp');
return;
}
if (registration.status === 'failed' && registration.error) {
setSubmitted(false);
setSendingId(null);
showApiError(registration.error);
// Registration is already complete for this number — what the customer
// wants is sign-in, not another OTP.
if (
registration.error.code === 'ALREADY_REGISTERED' ||
registration.error.code === 'MPIN_ALREADY_SET'
) {
// Recorded before the reset clears the number, and before the navigate
// below: sign-in reads the stored profile to tell a device that has
// completed registration from one that never has, and without it this
// number would be sent straight back here for another OTP.
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
navigation.navigate('Login');
}
}
}, [dispatch, navigation, registration, showApiError, submitted]);
/** One tap sends the code — there is no separate submit step any more. */
const handleSelectSim = (chosen: SimSubscription) => {
const number = chosen.phoneNumber;
if (!number || isLoading) {
return;
}
setSendingId(chosen.subscriptionId);
setSubmitted(true);
// orgId is fixed for this build and comes from config, so there is no field
// for it. The chosen SIM is not sent either: the API takes a number alone.
void dispatch(startRegistration({ mobileNumber: number }));
};
// Ten digits or nothing — the same contract the SIM read normalises to, so a
// typed number and a read one reach the API in one shape.
const manualDigits = manualNumber.replace(/D/g, '');
/** Silent until there is something to be wrong about, as sign-in is. */
const manualError = (() => {
if (manualDigits.length === 0 || isValidPhone(manualDigits)) {
return undefined;
}
return 'Enter the 10-digit number your bank has on file.';
})();
const handleManualChange = (value: string) => {
setManualNumber(value.replace(/D/g, ''));
};
const handleManualSubmit = () => {
if (!isValidPhone(manualDigits) || isLoading) {
return;
}
// Left null on purpose: no SIM row is sending, so none of them should spin.
setSendingId(null);
setSubmitted(true);
void dispatch(startRegistration({ mobileNumber: manualDigits }));
};
/**
* Why there is nothing to tap, and what the customer can do about it. Null
* whenever at least one line carries a readable number.
*/
const blocker = (() => {
if (detecting || !sim || tappable.length > 0) {
return null;
}
if (!sim.hasPermission) {
return {
icon: 'lock-closed-outline' as const,
title: 'Allow phone access',
text: 'Your number is read straight from the SIM, so you never have to type it. Nothing else on this phone is read.',
action: 'Allow phone access',
kind: 'permission' as const,
};
}
/*
* The slots listed but no number came back, and the reason is a permission
* this app has not been given — not the carrier.
*
* This case had no branch at all before, so it fell through to the carrier
* message below and told the customer to visit a branch. On Android 13 and
* up it is the likeliest failure of the three, because READ_PHONE_NUMBERS
* is a separate grant from READ_PHONE_STATE.
*/
if (!sim.canReadNumbers) {
return {
icon: 'key-outline' as const,
title: 'One more permission needed',
text: 'This phone keeps the SIM number behind its own permission. Allow it and your number is filled in for you.',
action: 'Allow and continue',
kind: 'permission' as const,
};
}
if (!sim.hasSim) {
return {
icon: 'phone-portrait-outline' as const,
title: 'No SIM found',
text: 'Put the SIM whose number your bank has into this phone, then check again.',
action: 'Check again',
kind: 'retry' as const,
};
}
// The ordinary case: a SIM is present, the app may read numbers, and the
// carrier still never wrote one onto the SIM — so there is nothing to read
// and nowhere to send a code.
return {
icon: 'information-circle-outline' as const,
title: 'Your carrier does not share this number',
text:
sims.length > 1
? 'Neither line lets apps read its number, so there is nothing to tap. Type the number in and the code still comes through.'
: 'This SIM does not let apps read its number, so there is nothing to tap. Type the number in and the code still comes through.',
action: 'Check again',
kind: 'retry' as const,
};
})();
/** True once Android has stopped offering the dialog for a permission ask. */
const needsSettings = blocker?.kind === 'permission' && permissionBlocked;
const handleBlockerAction = useCallback(async () => {
if (blocker?.kind !== 'permission') {
setAttempt((value) => value + 1);
return;
}
if (permissionBlocked) {
// The AppState listener above re-reads when the customer comes back.
void Linking.openSettings();
return;
}
const { outcome, info } = await simCard.requestAndRead();
setSim(info);
if (outcome === 'blocked') {
setPermissionBlocked(true);
}
}, [blocker?.kind, permissionBlocked]);
const numberError = registration.error?.fieldErrors?.mobileNumber;
const manualLink = (
<Pressable onPress={() => setManualOpen(true)} hitSlop={6}>
<Text
style={[theme.text.bodySmall, styles.manualLink, { color: theme.colors.onBrandSecondary }]}
>
Number not showing?{' '}
<Text style={{ color: theme.colors.onBrandPrimary }}>Type it instead</Text>
</Text>
</Pressable>
);
/*
A solid surface, not the translucent `onBrandSurface` the blocker sits on.
The field inside carries the design system's light-context colours — a
dark-grey label over half-transparent white over navy is not readable — so
the card brings its own light surface, exactly as the InfoBanner does.
*/
const manualCard = (
<View
style={[
styles.manual,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
]}
>
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
Enter your mobile number
</Text>
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>
The number your bank holds for you. We&apos;ll text a one-time code to it.
</Text>
<Input
label="Mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={manualNumber}
onChangeText={handleManualChange}
onSubmitEditing={handleManualSubmit}
returnKeyType="done"
placeholder="98765 43210"
autoFocus
editable={!isLoading}
// The server's own complaint about the number belongs on the field that
// holds it, rather than in a banner elsewhere on the screen.
error={manualError ?? numberError}
/>
<Button
title="Send code"
onPress={handleManualSubmit}
disabled={!isValidPhone(manualDigits)}
// Only a manual send leaves `sendingId` null, so a SIM row's request
// cannot put this button into a spin it does not own.
loading={isLoading && sendingId === null}
/>
<Pressable onPress={() => setManualOpen(false)} hitSlop={6}>
<Text
style={[
theme.text.bodySmall,
styles.centreText,
styles.manualDismiss,
{ color: theme.colors.textSecondary },
]}
>
{tappable.length > 0 ? 'Use a SIM instead' : 'Back'}
</Text>
</Pressable>
</View>
);
return (
<View style={styles.flex}>
{/*
The brand wash now fills the screen rather than sitting in a rounded
banner at the top. It is the same three stops the sign-in screen uses,
run vertically, so the two auth screens are one surface rather than two
different navies.
*/}
<LinearGradient
colors={theme.colors.brandGradient}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Dark full-bleed surface, so the status-bar icons have to be light. */}
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
{/* The number can be typed on this screen now, so it needs the same
keyboard handling sign-in has — without it the field and the send
button sit under the keyboard on a short handset. */}
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
{navigation.canGoBack() ? (
<Pressable
onPress={() => navigation.goBack()}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel="Go back"
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
</Pressable>
) : null}
{/* ── Brand and purpose ──────────────────────────────────────────── */}
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
<View style={styles.brandRow}>
<View
style={[
styles.mark,
{
backgroundColor: theme.colors.onBrandSurfaceStrong,
borderRadius: theme.radius.sm,
},
]}
>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
</View>
<Text style={[theme.text.h1, { color: theme.colors.onBrandPrimary }]}>BankCBS</Text>
</View>
<Text
accessibilityRole="header"
style={[theme.text.display, styles.heroTitle, { color: theme.colors.onBrandPrimary }]}
>
Let&apos;s set up{'\n'}your banking
</Text>
<Text
style={[theme.text.body, styles.heroSub, { color: theme.colors.onBrandSecondary }]}
>
Tap the SIM registered with your bank. We&apos;ll read the number straight from it
and text you a one-time code.
</Text>
<View style={styles.trustRow}>
{TRUST_MARKS.map((item) => (
<View
key={item.label}
style={[
styles.trustChip,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
{/* Solid white: the chip's translucent fill lightens the wash,
so a translucent label on it drops to 4.37:1 up here where
the gradient is lightest. */}
<Text style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}>
{item.label}
</Text>
</View>
))}
</View>
</Animated.View>
{/*
The SIM choice, directly on the wash. It used to be wrapped in a
white card pinned to the foot of the screen; the card was a second
surface doing nothing the cards inside it were not already doing,
and pinning it left a tall empty band above.
*/}
<Animated.View entering={FadeInUp.delay(120).duration(420)} style={styles.simBlock}>
{/* Checked ahead of `detecting`: the AppState listener re-reads the
SIMs every time the app is foregrounded, and a half-typed
number must not be swapped out for a spinner about something
the customer has already chosen to go around. */}
{manualOpen ? (
manualCard
) : detecting ? (
<View
style={[
styles.detecting,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.md,
},
]}
>
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
<Text
style={[
theme.text.bodySmall,
styles.detectingText,
// On a fill, so solid rather than translucent.
{ color: theme.colors.onBrandPrimary },
]}
>
Reading the SIMs in this phone…
</Text>
</View>
) : blocker ? (
<View
style={[
styles.blocker,
{
backgroundColor: theme.colors.onBrandSurface,
borderColor: theme.colors.onBrandBorder,
borderRadius: theme.radius.lg,
},
]}
>
<View
style={[
styles.blockerIcon,
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
]}
>
<Ionicons
name={needsSettings ? 'settings-outline' : blocker.icon}
size={20}
color={theme.colors.onBrandPrimary}
/>
</View>
<Text
style={[
theme.text.h3,
styles.centreText,
{ color: theme.colors.onBrandPrimary },
]}
>
{needsSettings ? 'Grant it in Settings' : blocker.title}
</Text>
<Text
style={[
theme.text.bodySmall,
styles.centreText,
// On a fill, so solid rather than translucent.
{ color: theme.colors.onBrandPrimary },
]}
>
{needsSettings
? 'Android will not show the permission prompt again for this app. Open Settings, allow Phone, and come back — we will pick it up from there.'
: blocker.text}
</Text>
{/* White fill on the wash: `secondary` here would be a white
button on a white card, which is not the situation any more. */}
<Button
title={needsSettings ? 'Open Settings' : blocker.action}
variant="inverse"
onPress={() => {
void handleBlockerAction();
}}
style={styles.retry}
/>
{manualLink}
</View>
) : (
<>
<SimChooser
sims={sims}
busyId={sendingId}
onSelect={handleSelectSim}
disabled={isLoading}
onBrand
/>
{/* An InfoBanner rather than bare red text: `danger` is a dark
red and would sit at roughly 1.5:1 against the navy. The
banner brings its own light surface, so the message stays
readable wherever it is put. */}
{numberError ? <InfoBanner tone="danger" message={numberError} /> : null}
{/* A line the carrier will not name still has to get a code
somehow, and a customer registering a number that is not in
this phone at all has no row to tap either. */}
{manualLink}
</>
)}
</Animated.View>
{/*
Nothing is pinned to the foot of this screen.
The content is centred in the viewport instead, so the space falls
evenly above and below the SIM cards rather than pooling under them.
An empty band at the bottom of a screen reads as unfinished; the
same amount of space, split, reads as breathing room.
*/}
<View style={styles.footer}>
{/* Only worth offering when a profile is actually stored here —
otherwise it leads to an MPIN screen with nothing to sign in to. */}
{lastProfile ? (
<Pressable onPress={() => navigation.navigate('Login')} hitSlop={6}>
<Text
style={[
theme.text.bodySmall,
styles.footerLink,
{ color: theme.colors.onBrandSecondary },
]}
>
Already registered?{' '}
<Text style={{ color: theme.colors.onBrandPrimary }}>Sign in</Text>
</Text>
</Pressable>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
centreText: {
textAlign: 'center',
},
scroll: {
flexGrow: 1,
// Centred, not top-aligned: with nothing pinned to the foot, the leftover
// height splits evenly above and below the content instead of pooling into
// one empty band under the SIM cards.
justifyContent: 'center',
paddingTop: 14,
paddingBottom: 28,
paddingHorizontal: 22,
gap: 30,
},
back: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
heroBody: { gap: 10 },
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 9,
},
mark: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
heroTitle: {
marginTop: 4,
},
heroSub: {
maxWidth: 340,
},
trustRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 6,
},
trustChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 1,
paddingVertical: 5,
paddingHorizontal: 10,
},
simBlock: {
gap: 12,
},
detecting: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
paddingVertical: 12,
paddingHorizontal: 14,
},
detectingText: { flex: 1 },
blocker: {
alignItems: 'center',
gap: 8,
borderWidth: 1,
padding: 18,
},
blockerIcon: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
retry: {
marginTop: 6,
},
manual: {
gap: 12,
borderWidth: 1,
padding: 18,
},
manualLink: {
textAlign: 'center',
paddingVertical: 10,
},
manualDismiss: {
paddingVertical: 4,
},
footer: {
gap: 4,
},
footerLink: {
textAlign: 'center',
paddingVertical: 11,
},
});
@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useTheme, fontFamily } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useToast } from '../../../hooks/useToast';
import { authActions } from '../store/authSlice';
import { selectRegistration } from '../store/authSelectors';
import {
rememberRegisteredProfile,
startRegistration,
verifyRegistrationOtp,
} from '../store/authThunk';
import { isValidOtp } from '../utils/authValidation';
import { CodeInput } from '../components/CodeInput';
import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'VerifyOtp'>>;
const RESEND_COOLDOWN_SECONDS = 30;
export default function VerifyOtpScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const showApiError = useApiErrorToast();
const registration = useAppSelector(selectRegistration);
const [otp, setOtp] = useState('');
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS);
const pending = useRef<'verify' | 'resend' | null>(null);
/** The deferred navigation frame — see `afterStateSettles`. */
const navFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (navFrame.current !== null) {
cancelAnimationFrame(navFrame.current);
}
},
[],
);
/**
* Runs a navigation action one frame after the dispatches that sit beside it.
*
* React batches an effect's work into a single commit, and the branches below
* each dispatch *and* move the stack. That one commit then tells Fabric to do
* two incompatible things at once: the dispatch re-renders the Register
* screen still mounted under this one — its footer link, its error row — while
* the reset removes that very subtree. Mounting the result throws "The
* specified child already has a parent", which is a red box in debug and a
* blank screen with no way to sign in once it is dismissed.
*
* The navigator already froze `initialRouteName` against the same collision;
* this is the other half of it. A frame's gap splits the work into two
* commits that each describe one change: the state settles, then the stack
* moves. It is not a race — `pending` is cleared before the frame is
* queued, so nothing re-enters in between.
*/
const afterStateSettles = useCallback((run: () => void) => {
if (navFrame.current !== null) {
cancelAnimationFrame(navFrame.current);
}
navFrame.current = requestAnimationFrame(() => {
navFrame.current = null;
run();
});
}, []);
const isLoading = registration.status === 'loading';
const fieldErrors = registration.error?.fieldErrors;
useEffect(() => {
if (cooldown <= 0) {
return;
}
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
useEffect(() => {
if (!pending.current) {
return;
}
if (registration.status === 'succeeded') {
const action = pending.current;
pending.current = null;
if (action === 'resend') {
setOtp('');
setCooldown(RESEND_COOLDOWN_SECONDS);
toast('Code sent', 'success', 'Check your messages for a new code.');
return;
}
/**
* Where verification lands depends on whether this profile owes an MPIN.
*
* `mpinSetupRequired: false` means one is already set — the customer is
* re-binding a device or has reinstalled — so asking them to choose a new
* MPIN would be wrong twice over: the create endpoint refuses it with
* `MPIN_ALREADY_SET`, and offering the choice implies the old one no
* longer works, which sends people to a branch for a reset they do not
* need. They go to sign-in and enter the MPIN they have.
*
* The stack is reset rather than pushed. The setup token is spent either
* way, so every screen behind this one is a dead end — and on the
* already-registered path there is nothing left to go back FOR.
*/
if (!registration.mpinSetupRequired) {
dispatch(authActions.resetRegistration());
toast(
'You are already registered',
'info',
'This device is set up. Sign in with your existing MPIN.',
);
afterStateSettles(() => navigation.reset({ index: 0, routes: [{ name: 'Login' }] }));
return;
}
// The setup token is single use and lasts five minutes, so MPIN creation
// follows immediately rather than sitting behind another screen.
if (registration.setupToken) {
navigation.navigate('CreateMpin');
}
return;
}
if (registration.status === 'failed' && registration.error) {
pending.current = null;
showApiError(registration.error);
setOtp('');
const { code } = registration.error;
// Out of attempts, or the window closed — either way the OTP on this
// screen can never succeed, so start over rather than let them keep trying.
if (code === 'OTP_ATTEMPTS_EXCEEDED' || code === 'OTP_EXPIRED') {
dispatch(authActions.resetRegistration());
afterStateSettles(() => navigation.navigate('Register'));
return;
}
// Same profile, already set up — sign-in is the only step left. Recorded
// before the reset clears the number, so sign-in asks for the MPIN
// instead of reading a blank profile as "never registered" and sending
// them back to the start of registration.
if (code === 'ALREADY_REGISTERED' || code === 'MPIN_ALREADY_SET') {
void dispatch(
rememberRegisteredProfile({
orgId: registration.orgId,
mobileNumber: registration.mobileNumber,
}),
);
dispatch(authActions.resetRegistration());
afterStateSettles(() => navigation.navigate('Login'));
}
}
}, [afterStateSettles, dispatch, navigation, registration, showApiError, toast]);
const handleVerify = () => {
if (!isValidOtp(otp) || isLoading) {
return;
}
pending.current = 'verify';
void dispatch(verifyRegistrationOtp({ otp }));
};
const handleResend = () => {
if (cooldown > 0 || isLoading) {
return;
}
pending.current = 'resend';
void dispatch(
startRegistration({
mobileNumber: registration.mobileNumber,
orgId: registration.orgId,
}),
);
};
const maskedMobile = registration.mobileNumber
? `••••••${registration.mobileNumber.slice(-4)}`
: '';
return (
<ScreenShell
title="Enter the code"
subtitle={`Sent to ${maskedMobile}`}
withTabBar={false}
contentContainerStyle={styles.container}
>
<Card style={styles.formCard}>
<CodeInput
label="6-digit code"
value={otp}
onChangeText={setOtp}
length={6}
autoFocus
editable={!isLoading}
onSubmit={handleVerify}
error={fieldErrors?.otp}
/>
<Pressable onPress={handleResend} disabled={cooldown > 0 || isLoading} hitSlop={8}>
<Text
style={[
styles.resendText,
{ color: cooldown > 0 ? theme.colors.textMuted : theme.colors.primary },
]}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</Text>
</Pressable>
</Card>
{registration.accountsLinked > 0 && (
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
{registration.accountsLinked === 1
? '1 account found at your bank and ready to link.'
: `${registration.accountsLinked} accounts found at your bank and ready to link.`}
</Text>
</View>
)}
<Button
title="Verify"
onPress={handleVerify}
disabled={!isValidOtp(otp) || isLoading}
loading={isLoading}
style={styles.submitButton}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 16,
flexGrow: 1,
},
formCard: {
padding: 16,
gap: 16,
},
resendText: {
fontSize: 14,
fontFamily: fontFamily.semibold,
textAlign: 'center',
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
padding: 14,
borderRadius: 12,
},
noteText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
},
submitButton: {
marginTop: 'auto',
height: 54,
},
});
+141 -86
View File
@@ -1,96 +1,151 @@
import type { AuthTokens, UserProfile } from '../../../types/app';
import { demoUser } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { env } from '../../../config/env';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { endpoints } from '../../../services/api/endpoints';
import { deviceIdentity } from '../../../services/security/deviceIdentity';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import type {
CreateMpinResult,
LoginResult,
SessionIdentity,
StartRegistrationResult,
VerifyOtpResult,
} from '../../../types/cbs';
interface LoginRequest {
phone: string;
password: string;
}
/**
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
* like from the client — the server will not say more, because saying more
* would leak whether the profile exists. So drop the cached key, re-fetch, and
* try exactly once more. A second failure is a real problem worth surfacing.
*/
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
try {
return await call();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
mpinCrypto.invalidate();
return call();
}
interface LoginResponse {
challengeId: string;
phone: string;
}
throw error;
}
};
interface VerifyOtpRequest {
challengeId: string;
otp: string;
phone: string;
}
export const authService = {
/**
* Looks the customer up in their bank's core system, links every account it
* returns and sends an OTP to the handset.
*/
async startRegistration(mobileNumber: string, orgId: string = env.orgId) {
const response = await apiClient.post<StartRegistrationResult>(
endpoints.registration.start,
{ orgId, mobileNumber },
);
interface VerifyOtpResponse {
verified: true;
phone: string;
}
return response.data;
},
interface SetupMpinRequest {
phone: string;
mpin: string;
}
/**
* Verifies the OTP, binds the profile to this device and allocates an MMID to
* every linked account. Returns the single-use, five-minute setup token that
* MPIN creation requires.
*/
async verifyRegistrationOtp({
mobileNumber,
otp,
orgId = env.orgId,
}: {
mobileNumber: string;
otp: string;
orgId?: string;
}) {
const deviceId = await deviceIdentity.get();
interface SetupMpinResponse {
tokens: AuthTokens;
user: UserProfile;
}
const response = await apiClient.post<VerifyOtpResult>(endpoints.registration.verifyOtp, {
orgId,
mobileNumber,
otp,
deviceId,
});
const createDemoTokens = (): AuthTokens => ({
accessToken: 'demo-access-token',
refreshToken: 'demo-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
});
return response.data;
},
export const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginResponse, LoginRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
challengeId: 'otp_challenge_001',
phone,
},
request: async () => {
const response = await apiClient.post<LoginResponse>(endpoints.auth.login, { phone });
return response.data;
},
}),
}),
verifyOtp: builder.mutation<VerifyOtpResponse, VerifyOtpRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
verified: true,
phone,
},
request: async () => {
const response = await apiClient.post<VerifyOtpResponse>(endpoints.auth.verifyOtp, {
phone,
});
return response.data;
},
}),
}),
setupMpin: builder.mutation<SetupMpinResponse, SetupMpinRequest>({
queryFn: () =>
resolveWithMock({
mockData: () => ({
tokens: createDemoTokens(),
user: demoUser,
}),
request: async () => {
const response = await apiClient.post<SetupMpinResponse>(endpoints.auth.setupMpin);
return response.data;
},
}),
invalidatesTags: ['Auth', 'User'],
}),
}),
});
/**
* Creates the MPIN and completes registration. There is deliberately no
* orgId and no mobile number in the request — both come from the setup token,
* which is what stops a caller setting someone else's MPIN. The deviceId must
* match the one sent at verify-otp.
*/
async createMpin({ setupToken, mpin }: { setupToken: string; mpin: string }) {
return withKeyRetry(async () => {
const deviceId = await deviceIdentity.get();
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
export const {
useLoginMutation,
useVerifyOtpMutation,
useSetupMpinMutation,
} = authApi;
const response = await apiClient.post<CreateMpinResult>(endpoints.registration.mpin, {
setupToken,
deviceId,
encryptedMpin,
});
return response.data;
});
},
/**
* Verifies the MPIN and opens a session. The MPIN is encrypted exactly as it
* was at setup — same key, same payload shape.
*/
async login({
mobileNumber,
mpin,
orgId = env.orgId,
setupToken,
}: {
mobileNumber: string;
mpin: string;
orgId?: string;
/** See `LoginRequest.setupToken` — a device re-bind, and only that. */
setupToken?: string | null;
}) {
return withKeyRetry(async () => {
const deviceId = await deviceIdentity.get();
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<LoginResult>(endpoints.auth.login, {
orgId,
mobileNumber,
deviceId,
encryptedMpin,
// Spread rather than set: an ordinary sign-in must not carry the key at
// all, and `setupToken: undefined` is a key once this goes through
// JSON.stringify on some clients.
...(setupToken ? { setupToken } : {}),
});
return response.data;
});
},
/** Who the current session belongs to. Doubles as a keep-alive. */
async getIdentity() {
const response = await apiClient.get<SessionIdentity>(endpoints.auth.me);
return response.data;
},
/**
* Revokes the session immediately. The endpoint always answers 200, even for
* a token that was already dead — a different answer would tell a caller
* whether a token they hold is live. Nothing that happens here may block the
* local sign-out, so every failure is swallowed: the customer asked to be
* signed out, and a network error must not leave them signed in.
*/
async logout() {
try {
await apiClient.post(endpoints.auth.logout);
} catch {
// Intentionally ignored. The local session is cleared regardless.
}
},
};
+36 -2
View File
@@ -3,6 +3,40 @@ import type { RootState } from '../../../app/store';
export const selectAuthState = (state: RootState) => state.auth;
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;
export const selectAuthHydrating = (state: RootState) => state.auth.isHydrating;
export const selectPendingPhone = (state: RootState) => state.auth.pendingPhone;
export const selectChallengeId = (state: RootState) => state.auth.challengeId;
export const selectAuthRole = (state: RootState) => state.auth.role;
export const selectAuthSession = (state: RootState) => state.auth.session;
export const selectAuthMethod = (state: RootState) => state.auth.authMethod;
/** True only for a session opened against the live CBS backend. */
export const selectHasLiveSession = (state: RootState) =>
Boolean(state.auth.session?.live);
export const selectSignInStatus = (state: RootState) => state.auth.signInStatus;
export const selectSignInError = (state: RootState) => state.auth.signInError;
export const selectMpinLocked = (state: RootState) => state.auth.mpinLocked;
export const selectLastProfile = (state: RootState) => state.auth.lastProfile;
export const selectBiometricReady = (state: RootState) => state.auth.biometricReady;
export const selectRegistration = (state: RootState) => state.auth.registration;
export const selectRegistrationMobile = (state: RootState) =>
state.auth.registration.mobileNumber;
export const selectRegistrationStatus = (state: RootState) => state.auth.registration.status;
export const selectRegistrationError = (state: RootState) => state.auth.registration.error;
export const selectCustomerName = (state: RootState) => state.auth.session?.fullName ?? '';
/**
* The signed-in customer's own mobile number, from the session.
*
* This is profile data, not account data — it comes back with the session
* rather than from `/accounts`, which carries no mobile number.
*/
export const selectSessionMobile = (state: RootState) =>
state.auth.session?.mobileNumber;
/**
* True once the remembered profile has actually been read off the Keystore.
* Until then a null `lastProfile` means "unknown", not "never registered".
*/
export const selectSignInContextLoaded = (state: RootState) =>
state.auth.signInContextLoaded;
+260 -39
View File
@@ -1,80 +1,301 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthTokens } from '../../../types/app';
import { bootstrapAuthSession, logoutSession } from './authThunk';
import { env } from '../../../config/env';
import type { ApiError } from '../../../types/api';
import type { AuthSession, LoginRole } from '../../../types/app';
import type { AuthState } from '../types';
import {
bootstrapAuthSession,
completeMpinSetup,
disableBiometricSignIn,
loadSignInContext,
logoutSession,
rememberRegisteredProfile,
signIn,
signInAsAgent,
signInWithBiometrics,
startRegistration,
verifyRegistrationOtp,
} from './authThunk';
import type { LoginRole } from '../../../types/app';
interface AuthState {
isHydrating: boolean;
isAuthenticated: boolean;
pendingPhone: string;
challengeId: string | null;
tokens: AuthTokens | null;
authMethod: 'otp' | 'biometric' | null;
role: LoginRole;
}
const emptyRegistration = () => ({
orgId: env.orgId,
mobileNumber: '',
accountsLinked: 0,
mmidsAssigned: 0,
setupToken: null,
otpSent: false,
mpinSetupRequired: true,
status: 'idle' as const,
error: null,
});
const initialState: AuthState = {
isHydrating: true,
isAuthenticated: false,
pendingPhone: '',
challengeId: null,
tokens: null,
authMethod: null,
session: null,
role: 'User',
authMethod: null,
registration: emptyRegistration(),
signInStatus: 'idle',
signInError: null,
mpinLocked: false,
lastProfile: null,
rebindToken: null,
biometricReady: false,
signInContextLoaded: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setPendingPhone(state, action: PayloadAction<string>) {
state.pendingPhone = action.payload;
setRole(state, action: PayloadAction<LoginRole>) {
state.role = action.payload;
},
setChallengeId(state, action: PayloadAction<string>) {
state.challengeId = action.payload;
/** Clears a sign-in error so a fresh keystroke does not sit under a stale one. */
clearSignInError(state) {
state.signInError = null;
},
authenticate(state, action: PayloadAction<AuthTokens>) {
state.tokens = action.payload;
state.isAuthenticated = true;
state.isHydrating = false;
state.role = action.payload.role || 'User';
},
setAuthMethod(state, action: PayloadAction<'otp' | 'biometric'>) {
state.authMethod = action.payload;
resetRegistration(state) {
state.registration = emptyRegistration();
},
resetAuthFlow(state) {
state.challengeId = null;
state.pendingPhone = '';
state.registration = emptyRegistration();
state.signInStatus = 'idle';
state.signInError = null;
},
/**
* The customer must visit a branch to clear a lock, so this only exists for
* leaving the locked screen — it does not pretend to unlock anything.
*/
dismissMpinLock(state) {
state.mpinLocked = false;
},
},
extraReducers: (builder) => {
builder
/* Bootstrap */
.addCase(bootstrapAuthSession.pending, (state) => {
state.isHydrating = true;
})
.addCase(bootstrapAuthSession.fulfilled, (state, action) => {
state.tokens = action.payload;
state.isAuthenticated = Boolean(action.payload);
const { session, lastProfile, biometricReady } = action.payload;
state.session = session;
state.isAuthenticated = Boolean(session);
state.role = session?.role ?? 'User';
// A restored session cannot say how it was opened; what matters here is
// only whether it is a live CBS session or the local agent one.
state.authMethod = session ? (session.live ? 'mpin' : 'local') : null;
state.isHydrating = false;
state.role = action.payload?.role || 'User';
state.lastProfile = lastProfile;
state.biometricReady = biometricReady;
state.signInContextLoaded = true;
})
.addCase(bootstrapAuthSession.rejected, (state) => {
state.isHydrating = false;
state.isAuthenticated = false;
state.session = null;
state.role = 'User';
state.signInContextLoaded = true;
})
/* Registration — mobile number */
.addCase(startRegistration.pending, (state, action) => {
state.registration = {
...emptyRegistration(),
mobileNumber: action.meta.arg.mobileNumber,
orgId: action.meta.arg.orgId ?? env.orgId,
status: 'loading',
};
})
.addCase(startRegistration.fulfilled, (state, action) => {
state.registration.status = 'succeeded';
state.registration.otpSent = true;
state.registration.mobileNumber = action.payload.mobileNumber;
state.registration.orgId = action.payload.orgId;
state.registration.accountsLinked = action.payload.accountsLinked;
state.registration.error = null;
})
.addCase(startRegistration.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Registration — OTP */
.addCase(verifyRegistrationOtp.pending, (state) => {
state.registration.status = 'loading';
state.registration.error = null;
})
.addCase(verifyRegistrationOtp.fulfilled, (state, action) => {
state.registration.status = 'succeeded';
state.registration.setupToken = action.payload.setupToken;
state.registration.mmidsAssigned = action.payload.mmidsAssigned;
state.registration.mpinSetupRequired = action.payload.mpinSetupRequired;
/**
* A profile that already has an MPIN is a device this customer is
* re-binding, so record it as the last profile now.
*
* Without this the sign-in screen sees no stored profile and bounces
* straight back to registration — the OTP screen would send them to
* sign-in, sign-in would send them to Register, and the loop never
* settles. Recording it here is also just true: verify-otp has bound
* the profile to this device, which is exactly what `lastProfile`
* means.
*
* `fullName` is deliberately absent — verify-otp does not return one,
* and the field is optional precisely so it can be filled in later,
* when a session actually opens and the name is known.
*/
if (!action.payload.mpinSetupRequired) {
state.lastProfile = {
orgId: state.registration.orgId,
mobileNumber: state.registration.mobileNumber,
};
// No MPIN to create, so the token's only remaining use is the sign-in
// this customer is about to do. Held outside `registration` so the
// OTP screen's `resetRegistration()` cannot take it with it.
state.rebindToken = action.payload.setupToken;
}
})
.addCase(verifyRegistrationOtp.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Registration — MPIN, which also opens the session */
.addCase(completeMpinSetup.pending, (state) => {
state.registration.status = 'loading';
state.registration.error = null;
})
.addCase(completeMpinSetup.rejected, (state, action) => {
state.registration.status = 'failed';
state.registration.error = action.payload ?? null;
})
/* Sign in */
.addCase(signInAsAgent.fulfilled, (state, action) => {
applySession(state, action.payload, 'local');
})
.addCase(disableBiometricSignIn.fulfilled, (state) => {
state.biometricReady = false;
})
/**
* Handled on `pending` rather than `fulfilled` on purpose. The screens
* that dispatch this navigate to sign-in on the very next line, and
* sign-in decides whether to bounce back to registration from
* `lastProfile`. Waiting for the Keystore write would let that decision
* be made against a profile that is not there yet — the write takes tens
* of milliseconds on Android, which is plenty for the screen to mount.
* `pending` runs synchronously inside the dispatch, so it cannot race.
*/
.addCase(rememberRegisteredProfile.pending, (state, action) => {
const { orgId, mobileNumber } = action.meta.arg;
state.lastProfile = {
orgId,
mobileNumber,
// Keep a name we already hold for this same number; drop one that
// belonged to whoever was signed in here before.
fullName:
state.lastProfile?.mobileNumber === mobileNumber
? state.lastProfile.fullName
: undefined,
};
state.signInContextLoaded = true;
})
// Re-read after a logout wiped the store, so the sign-in screen still
// knows who was here and whether biometrics are set up.
.addCase(loadSignInContext.fulfilled, (state, action) => {
state.lastProfile = action.payload.lastProfile;
state.biometricReady = action.payload.biometricReady;
state.signInContextLoaded = true;
})
/* Logout */
.addCase(logoutSession.fulfilled, (state) => {
state.tokens = null;
state.session = null;
state.isAuthenticated = false;
state.pendingPhone = '';
state.challengeId = null;
state.authMethod = null;
// Logout wipes the whole store, so this reducer runs against
// `initialState` — where `isHydrating` is true. Left alone, the app
// would sit on the bootstrap loader instead of showing sign-in.
state.isHydrating = false;
state.role = 'User';
state.authMethod = null;
state.registration = emptyRegistration();
state.signInStatus = 'idle';
state.signInError = null;
})
// Matchers must follow every `addCase`, which is why the two sign-in
// paths that share reducers land here rather than above.
.addMatcher(isAnyOf(signIn.pending, signInWithBiometrics.pending), (state) => {
state.signInStatus = 'loading';
state.signInError = null;
})
.addMatcher(
isAnyOf(signIn.fulfilled, signInWithBiometrics.fulfilled, completeMpinSetup.fulfilled),
(state, action) => {
const method =
action.type === signInWithBiometrics.fulfilled.type ? 'biometric' : 'mpin';
applySession(state, action.payload as AuthSession, method);
state.registration = emptyRegistration();
// Spent. Cleared here rather than on `rejected` so a mistyped MPIN
// can be retried against the same token — a wrong MPIN does not
// consume it, and the server expires it on its own after five
// minutes either way.
state.rebindToken = null;
// This sign-in is also the only moment the MPIN is known good, so it
// is where opting into biometrics takes effect.
const arg = action.meta.arg as { rememberForBiometrics?: boolean } | undefined;
if (arg?.rememberForBiometrics) {
state.biometricReady = true;
}
},
)
.addMatcher(isAnyOf(signIn.rejected, signInWithBiometrics.rejected), (state, action) => {
const error = (action.payload as ApiError | undefined) ?? null;
state.signInStatus = 'failed';
state.signInError = error;
// Three wrong MPINs lock the profile and no in-app path clears it, so
// the app stops offering the form and says where to go instead.
if (error?.code === 'MPIN_LOCKED') {
state.mpinLocked = true;
}
});
},
});
function applySession(
state: AuthState,
session: AuthSession,
method: NonNullable<AuthState['authMethod']>,
) {
state.session = session;
state.isAuthenticated = true;
state.isHydrating = false;
state.role = session.role;
state.authMethod = method;
state.signInStatus = 'succeeded';
state.signInError = null;
state.mpinLocked = false;
state.lastProfile = {
orgId: session.orgId,
mobileNumber: session.mobileNumber,
fullName: session.fullName,
};
}
export const authActions = authSlice.actions;
export const authReducer = authSlice.reducer;
+473 -6
View File
@@ -1,13 +1,480 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { appConfig } from '../../../config/appConfig';
import { env } from '../../../config/env';
import { toApiError } from '../../../services/api/serviceHelpers';
import { deviceIdentity } from '../../../services/security/deviceIdentity';
import { sessionManager } from '../../../services/security/sessionManager';
import { purgeUserData } from '../../../services/storage/appDataPurge';
import { secureStorage } from '../../../services/storage/secureStorage';
import type { ApiError } from '../../../types/api';
import type { AuthSession } from '../../../types/app';
import type { LoginResult, StartRegistrationResult, VerifyOtpResult } from '../../../types/cbs';
import { authService } from '../services/authApi';
import type { AuthThunkState } from '../types';
export const bootstrapAuthSession = createAsyncThunk('auth/bootstrapSession', async () => {
const tokens = await secureStorage.getSession();
return tokens;
interface ThunkConfig {
state: AuthThunkState;
rejectValue: ApiError;
}
/**
* Adopt a freshly opened session: persist it, hand the server's own two
* timeouts to the session manager, and remember the profile so the sign-in
* screen can prefill it next time.
*/
const adoptSession = async (session: AuthSession) => {
await secureStorage.saveSession(session);
await secureStorage.saveLastProfile({
orgId: session.orgId,
mobileNumber: session.mobileNumber,
fullName: session.fullName,
});
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return session;
};
const toSession = (
result: LoginResult,
{ orgId, mobileNumber }: { orgId: string; mobileNumber: string },
): AuthSession => ({
sessionToken: result.sessionToken,
expiresAt: result.expiresAt,
idleTimeoutSeconds: result.idleTimeoutSeconds,
orgId,
mobileNumber,
fullName: result.fullName,
role: 'User',
live: true,
});
export const logoutSession = createAsyncThunk('auth/logoutSession', async () => {
await secureStorage.clearSession();
return true;
/* ── Registration ─────────────────────────────────────────────────────────── */
export const startRegistration = createAsyncThunk<
StartRegistrationResult & { mobileNumber: string; orgId: string },
{ mobileNumber: string; orgId?: string },
ThunkConfig
>('auth/startRegistration', async ({ mobileNumber, orgId = env.orgId }, { rejectWithValue }) => {
try {
// Minted here rather than at the first call that needs it, so the id the
// profile gets bound to exists before any of the registration steps run.
await deviceIdentity.get();
const result = await authService.startRegistration(mobileNumber, orgId);
return { ...result, mobileNumber, orgId };
} catch (error) {
return rejectWithValue(toApiError(error));
}
});
export const verifyRegistrationOtp = createAsyncThunk<
VerifyOtpResult,
{ otp: string },
ThunkConfig
>('auth/verifyRegistrationOtp', async ({ otp }, { getState, rejectWithValue }) => {
const { mobileNumber, orgId } = getState().auth.registration;
try {
const result = await authService.verifyRegistrationOtp({ mobileNumber, otp, orgId });
/**
* A profile that already holds an MPIN goes to the Keystore right away.
*
* The reducer puts the same profile into state, which is enough to route
* this session — but only the Keystore copy survives a relaunch, and this
* customer is about to be asked for an MPIN they may not remember. If they
* close the app at that prompt, the stored profile is what brings them back
* to sign-in instead of dumping them into registration to do the OTP again.
*
* Not awaited against the response: a Keystore write costs tens of
* milliseconds on Android and nothing here depends on it. A failed write
* costs the shortcut at next launch, not this registration, so it is
* swallowed rather than failing a verification the server already accepted.
*/
if (!result.mpinSetupRequired) {
void secureStorage.saveLastProfile({ orgId, mobileNumber }).catch(() => {});
}
return result;
} catch (error) {
return rejectWithValue(toApiError(error));
}
});
/**
* Creates the MPIN and, because the MPIN is still in hand, opens the session
* straight away — so registration lands the customer in the app rather than
* back on a sign-in form typing what they just chose.
*/
export const completeMpinSetup = createAsyncThunk<
AuthSession,
{ mpin: string; rememberForBiometrics?: boolean },
ThunkConfig
>(
'auth/completeMpinSetup',
async ({ mpin, rememberForBiometrics = false }, { getState, rejectWithValue }) => {
const { mobileNumber, orgId, setupToken } = getState().auth.registration;
if (!setupToken) {
return rejectWithValue({
status: 409,
message: 'Your setup window has closed. Start registration again.',
code: 'OTP_NOT_VERIFIED',
});
}
try {
await authService.createMpin({ setupToken, mpin });
const result = await authService.login({ mobileNumber, mpin, orgId });
if (rememberForBiometrics) {
await secureStorage.saveBiometricMpin(mpin);
await secureStorage.setBiometricEnabled(true);
}
return adoptSession(toSession(result, { orgId, mobileNumber }));
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/* ── Sign in ──────────────────────────────────────────────────────────────── */
export const signIn = createAsyncThunk<
AuthSession,
{ mobileNumber: string; mpin: string; orgId?: string; rememberForBiometrics?: boolean },
ThunkConfig
>(
'auth/signIn',
async (
{ mobileNumber, mpin, orgId = env.orgId, rememberForBiometrics = false },
{ getState, rejectWithValue },
) => {
try {
// Null on an ordinary sign-in, which is the overwhelming majority of
// them; set only when verify-otp has just answered
// `mpinSetupRequired: false` and sent the customer here to use the MPIN
// they already have.
const { rebindToken } = getState().auth;
const result = await authService.login({
mobileNumber,
mpin,
orgId,
setupToken: rebindToken,
});
if (rememberForBiometrics) {
await secureStorage.saveBiometricMpin(mpin);
await secureStorage.setBiometricEnabled(true);
}
return adoptSession(toSession(result, { orgId, mobileNumber }));
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/**
* The API opens a session only against an encrypted MPIN — there is no refresh
* token to trade in — so biometric sign-in unlocks the MPIN the customer chose
* to store behind the device prompt and then signs in with it normally.
*/
export const signInWithBiometrics = createAsyncThunk<AuthSession, void, ThunkConfig>(
'auth/signInWithBiometrics',
async (_, { rejectWithValue }) => {
const profile = await secureStorage.getLastProfile();
if (!profile) {
return rejectWithValue({
status: 409,
message: 'Sign in with your MPIN once to enable biometrics.',
code: 'MPIN_NOT_SET',
});
}
const mpin = await secureStorage.getBiometricMpin(
appConfig.security.biometricPromptTitle,
);
if (!mpin) {
return rejectWithValue({
status: 401,
message: 'Biometric sign-in was cancelled.',
code: 'BIOMETRIC_CANCELLED',
});
}
try {
const result = await authService.login({
mobileNumber: profile.mobileNumber,
mpin,
orgId: profile.orgId,
});
return adoptSession(
toSession(result, { orgId: profile.orgId, mobileNumber: profile.mobileNumber }),
);
} catch (error) {
const apiError = toApiError(error);
// A stored MPIN that no longer works is worse than no stored MPIN — it
// would burn an attempt against the three-strike lock on every try.
if (apiError.code === 'INVALID_CREDENTIALS' || apiError.code === 'MPIN_LOCKED') {
await secureStorage.setBiometricEnabled(false);
}
return rejectWithValue(apiError);
}
},
);
/**
* The field-agent application has no CBS backend behind it yet, so it runs on a
* local session rather than pretending to have authenticated against the bank.
*/
export const signInAsAgent = createAsyncThunk<AuthSession, { mobileNumber: string }>(
'auth/signInAsAgent',
async ({ mobileNumber }) => {
const session: AuthSession = {
sessionToken: 'local-agent-session',
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
idleTimeoutSeconds: 300,
orgId: env.orgId,
mobileNumber,
fullName: 'Field Agent',
role: 'Agent',
live: false,
};
await secureStorage.saveSession(session);
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return session;
},
);
/**
* Turning biometric sign-in off is safe from anywhere: drop the stored MPIN and
* the flag. Turning it *on* is not, and deliberately has no thunk — verifying
* an MPIN means opening a session, and one active session per profile would
* revoke the session the customer is currently using. So it can only be enabled
* at the moment the MPIN is already known good: at sign-in, or at MPIN setup.
*/
export const disableBiometricSignIn = createAsyncThunk<void, void>(
'auth/disableBiometricSignIn',
async () => {
await secureStorage.setBiometricEnabled(false);
},
);
/* ── Session lifecycle ────────────────────────────────────────────────────── */
export const bootstrapAuthSession = createAsyncThunk<{
session: AuthSession | null;
lastProfile: Awaited<ReturnType<typeof secureStorage.getLastProfile>>;
biometricReady: boolean;
}>('auth/bootstrapSession', async () => {
// Established before anything can need it, so no call ever races the mint.
await deviceIdentity.get();
const [stored, lastProfile, biometricEnabled, biometricStored] = await Promise.all([
secureStorage.getSession(),
secureStorage.getLastProfile(),
secureStorage.getBiometricEnabled(),
secureStorage.hasBiometricMpin(),
]);
const biometricReady = Boolean(biometricEnabled && biometricStored && lastProfile);
if (!stored) {
return { session: null, lastProfile, biometricReady };
}
/**
* Policy: every launch asks for the MPIN, so a stored session is dropped
* rather than resumed. `lastProfile` still comes back, which is what lets the
* sign-in screen ask for an MPIN alone instead of the mobile number too.
*
* The token is only deleted locally, not revoked. Signing in again opens a
* new session and the backend allows one per profile, so the old one dies
* then anyway — and a network call on the launch path would hang the splash
* whenever the bank is unreachable. Deleting our only copy is what makes it
* unusable in the meantime.
*/
if (appConfig.security.requireMpinOnLaunch) {
await secureStorage.clearSession();
sessionManager.clear();
return { session: null, lastProfile, biometricReady };
}
// A token past its absolute expiry is dead no matter what; do not spend a
// round trip proving it.
if (Date.parse(stored.expiresAt) <= Date.now()) {
await secureStorage.clearSession();
return { session: null, lastProfile, biometricReady };
}
if (!stored.live) {
sessionManager.configure({
idleTimeoutSeconds: stored.idleTimeoutSeconds,
expiresAt: stored.expiresAt,
});
return { session: stored, lastProfile, biometricReady };
}
try {
// Confirms the token against the server rather than trusting what we
// stored, and refreshes the idle timer on the way through.
const identity = await authService.getIdentity();
const session: AuthSession = {
...stored,
fullName: identity.fullName,
orgId: identity.orgId,
mobileNumber: identity.mobileNumber,
expiresAt: identity.expiresAt,
};
await secureStorage.saveSession(session);
sessionManager.configure({
idleTimeoutSeconds: session.idleTimeoutSeconds,
expiresAt: session.expiresAt,
});
return { session, lastProfile, biometricReady };
} catch {
// Signing in elsewhere signs this device out — one active session per
// profile — so a dead token here is expected, not exceptional.
await secureStorage.clearSession();
return { session: null, lastProfile, biometricReady };
}
});
/**
* Just the sign-in context: the remembered profile and whether biometrics are
* set up. Logout wipes all Redux state, and these two live in the Keystore, so
* they have to be re-read — but without `bootstrapAuthSession`'s hydrating
* flag, which would blank the app behind a loader on the way to sign-in.
*/
export const loadSignInContext = createAsyncThunk<{
lastProfile: Awaited<ReturnType<typeof secureStorage.getLastProfile>>;
biometricReady: boolean;
}>('auth/loadSignInContext', async () => {
const [lastProfile, biometricEnabled, biometricStored] = await Promise.all([
secureStorage.getLastProfile(),
secureStorage.getBiometricEnabled(),
secureStorage.hasBiometricMpin(),
]);
return {
lastProfile,
biometricReady: Boolean(biometricEnabled && biometricStored && lastProfile),
};
});
/**
* Records a profile the server has just told us is already registered on this
* device, at a point where there is no session to learn it from.
*
* `ALREADY_REGISTERED` and `MPIN_ALREADY_SET` both mean the same thing to the
* app: stop asking for an OTP, ask for the MPIN. The only way a screen can act
* on that is to send the customer to sign-in — and sign-in reads `lastProfile`
* to decide whether this device has ever completed registration. With nothing
* stored it sends them straight back to registration, which answers
* `ALREADY_REGISTERED` again. That is a loop with no way out of it, and it is
* exactly the customer who has reinstalled the app who falls into it.
*
* `fullName` is deliberately absent: no registration response carries one, and
* the field is optional so it can be filled in the first time a session opens.
*/
export const rememberRegisteredProfile = createAsyncThunk<
void,
{ orgId: string; mobileNumber: string }
>('auth/rememberRegisteredProfile', async ({ orgId, mobileNumber }) => {
await secureStorage.saveLastProfile({ orgId, mobileNumber });
});
/** Keep-alive. Also the cheapest way to notice a session revoked elsewhere. */
export const refreshSessionIdentity = createAsyncThunk<void, void, ThunkConfig>(
'auth/refreshSessionIdentity',
async (_, { rejectWithValue }) => {
try {
await authService.getIdentity();
} catch (error) {
return rejectWithValue(toApiError(error));
}
},
);
/**
* Why the session is ending.
*
* It decides how much of the handset is wiped, so the two are not the same
* event dressed differently. A customer who taps Log out is done with this
* phone and everything of theirs comes off it; an idle timeout or a 401 is the
* app protecting a session they are in the middle of, and taking their
* registration with it would mean an OTP and a fresh device bind to get back
* into an account they never left.
*/
export type LogoutReason = 'user' | 'expired' | 'unauthorized';
/**
* Ends the session, and on a deliberate sign-out leaves nothing of the
* customer behind.
*
* The reason defaults to `user`, which is the wiping path. That direction is
* chosen on purpose: a call site added later that says nothing clears too much
* rather than too little, and the two places that must not wipe say so out
* loud where anyone reading them can see why.
*
* The local clear is unconditional either way. Revoking server-side is
* attempted first and allowed to fail — a handset with no signal must still be
* able to sign out, and a session the server keeps alive until its own expiry
* is a far smaller problem than one whose MPIN and mobile number stay on a
* phone the customer has just handed to someone else.
*
* Redux is separately reset to `undefined` by `rootReducer` on this thunk's
* `fulfilled` action, whatever the reason. Store and storage together are the
* whole of what the app held; see `purgeUserData` for what survives and why.
*/
export const logoutSession = createAsyncThunk<boolean, LogoutReason | void>(
'auth/logoutSession',
async (reason) => {
try {
const session = await secureStorage.getSession();
if (session?.live) {
await authService.logout();
}
} catch {
// Reading or revoking may fail; clearing locally must not.
}
if (reason === undefined || reason === 'user') {
await purgeUserData();
} else {
// The session ends; the registration does not. Same as it has always
// been on these two paths.
await secureStorage.clearSession();
sessionManager.clear();
}
return true;
},
);
+78 -1
View File
@@ -1 +1,78 @@
export type { AuthTokens, UserProfile } from '../../../types/app';
import type { ApiError } from '../../../types/api';
import type { AsyncStatus, AuthSession, LoginRole } from '../../../types/app';
import type { LastProfile } from '../../../services/storage/secureStorage';
export type { AuthSession, LoginRole, UserProfile } from '../../../types/app';
export type { LastProfile } from '../../../services/storage/secureStorage';
/** How the current session was opened. */
export type AuthMethod = 'mpin' | 'biometric' | 'local';
/**
* The three registration calls in flight. `setupToken` is single-use and lasts
* five minutes, so it lives here only for the hop between OTP verification and
* MPIN creation and is dropped the moment either finishes.
*/
export interface RegistrationState {
orgId: string;
mobileNumber: string;
accountsLinked: number;
mmidsAssigned: number;
setupToken: string | null;
otpSent: boolean;
/**
* Whether this profile still owes an MPIN, as reported by verify-otp.
*
* False means the customer already set one — they are re-registering a
* device, or reinstalling — so the app must ask for the MPIN they have
* rather than offering to create a second one. The create-MPIN endpoint
* would refuse it anyway (`MPIN_ALREADY_SET`), so sending them there is a
* dead end dressed up as a step.
*
* Defaults true: only a verify-otp response can lower it, and if the field
* were ever missing, offering to create is the state registration has
* always assumed.
*/
mpinSetupRequired: boolean;
status: AsyncStatus;
error: ApiError | null;
}
export interface AuthState {
isHydrating: boolean;
isAuthenticated: boolean;
session: AuthSession | null;
role: LoginRole;
authMethod: AuthMethod | null;
registration: RegistrationState;
signInStatus: AsyncStatus;
signInError: ApiError | null;
/**
* Three wrong MPINs lock the profile and there is no in-app unlock, so this
* is a dead end the UI has to state plainly rather than a retryable error.
*/
mpinLocked: boolean;
lastProfile: LastProfile | null;
/**
* The verify-otp setup token, kept for the sign-in that follows it.
*
* Lives outside `registration` on purpose. The OTP screen calls
* `resetRegistration()` on its way to sign-in — registration really is over
* on this path, there is no MPIN to create — and the token has to outlive
* that reset to reach the login call. Dropped the moment a session opens.
*/
rebindToken: string | null;
/** A stored MPIN behind the device prompt, so biometric sign-in can work. */
biometricReady: boolean;
/**
* Whether `lastProfile` has actually been read off the Keystore yet. Without
* this, a null profile is ambiguous — not-yet-loaded looks identical to
* never-registered, and the two send the customer to different screens.
*/
signInContextLoaded: boolean;
}
/** Minimal shape thunks need from the store, avoiding a cycle through it. */
export interface AuthThunkState {
auth: AuthState;
}
+25
View File
@@ -1,5 +1,30 @@
import { MPIN_LENGTH } from '../../../utils/validation';
export {
MPIN_LENGTH,
isRepeatedDigits,
isValidMpin,
isValidOtp,
isValidPhone,
} from '../../../utils/validation';
/** Inline copy for why an MPIN was rejected before it ever left the device. */
export const describeMpinProblem = (value: string): string | undefined => {
if (value.length === 0) {
return undefined;
}
if (!/^[0-9]*$/.test(value)) {
return 'Digits only';
}
if (value.length !== MPIN_LENGTH) {
return `An MPIN is ${MPIN_LENGTH} digits`;
}
if (/^(\d)\1*$/.test(value)) {
return 'Pick an MPIN that is not the same digit repeated';
}
return undefined;
};
@@ -2,7 +2,7 @@ import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { AwarenessArticle } from '../types';
interface AwarenessCardProps {
@@ -102,7 +102,7 @@ const styles = StyleSheet.create({
},
category: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
@@ -113,11 +113,11 @@ const styles = StyleSheet.create({
},
urgentText: {
fontSize: 9,
fontWeight: '900',
fontFamily: fontFamily.bold,
},
title: {
fontSize: 16,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
bookmarkBtn: {
padding: 4,
@@ -143,7 +143,7 @@ const styles = StyleSheet.create({
},
tagText: {
fontSize: 10,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
readMore: {
flexDirection: 'row',
@@ -152,6 +152,6 @@ const styles = StyleSheet.create({
},
readMoreText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
@@ -5,7 +5,9 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { Card, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { FeatureKey } from '../../../constants/featureKeys';
import { FeatureGate } from '../../../features';
import { useTheme, fontFamily } from '../../../theme';
import { useAwareness } from '../hooks/useAwareness';
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessDetail'>;
@@ -105,6 +107,9 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
</View>
</View>
{/* The report button's only destination is the service-requests hub, so
it goes when the hub does rather than becoming a dead tap. */}
<FeatureGate feature={FeatureKey.SERVICES_REQUESTS_HUB}>
<View style={styles.footerActions}>
<Pressable
onPress={() => navigation.navigate('ServiceRequestsHub')}
@@ -114,6 +119,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
</Pressable>
</View>
</FeatureGate>
</View>
<View style={{ height: 20 }} />
</ScreenShell>
@@ -149,17 +155,17 @@ const styles = StyleSheet.create({
},
categoryText: {
fontSize: 11,
fontWeight: '800',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
dateText: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
title: {
fontSize: 24,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
lineHeight: 30,
marginBottom: 12,
@@ -177,7 +183,7 @@ const styles = StyleSheet.create({
},
tagText: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
introCard: {
padding: 16,
@@ -186,7 +192,7 @@ const styles = StyleSheet.create({
description: {
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
contentSection: {
marginBottom: 32,
@@ -216,7 +222,7 @@ const styles = StyleSheet.create({
},
ctaTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
ctaSubtitle: {
fontSize: 13,
@@ -237,7 +243,7 @@ const styles = StyleSheet.create({
},
reportText: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
errorContainer: {
padding: 20,
@@ -5,7 +5,7 @@ import Ionicons from '@react-native-vector-icons/ionicons';
import { Input, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import { AwarenessCard } from '../components/AwarenessCard';
import { useAwareness } from '../hooks/useAwareness';
import { AwarenessCategory } from '../types';
@@ -27,7 +27,10 @@ export default function AwarenessListScreen({ navigation }: Props) {
};
return (
<ScreenShell title="Security aware" subtitle="Learn to protect your digital banking">
<ScreenShell
// The body is a FlatList, so it owns the scrolling.
scrollable={false}
title="Security aware" subtitle="Learn to protect your digital banking">
<View style={styles.searchContainer}>
<Input
placeholder="Search safety tips, fraud alerts..."
@@ -152,7 +155,7 @@ const styles = StyleSheet.create({
},
filterText: {
fontSize: 13,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
listContent: {
paddingHorizontal: 16,
@@ -170,7 +173,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
urgentCarousel: {
@@ -192,7 +195,7 @@ const styles = StyleSheet.create({
},
urgentCardTitle: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
urgentCardDesc: {
fontSize: 12,
@@ -205,7 +208,7 @@ const styles = StyleSheet.create({
},
emptyTitle: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
emptySubtitle: {
fontSize: 14,
@@ -6,7 +6,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface CardControlToggleProps {
label: string;
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
track: {
width: 52,
@@ -2,7 +2,7 @@ import React, { memo, useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
import type { CardPhysicalDelivery } from '../../../types/app';
import { formatDate } from '../../../utils/formatters';
import {
@@ -185,13 +185,13 @@ const styles = StyleSheet.create({
},
eyebrow: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
title: {
fontSize: 17,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.2,
},
subtitle: {
@@ -208,7 +208,7 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
metricsRow: {
flexDirection: 'row',
@@ -222,13 +222,13 @@ const styles = StyleSheet.create({
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
},
metricValue: {
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
timelineRow: {
flexDirection: 'row',
@@ -257,7 +257,7 @@ const styles = StyleSheet.create({
},
timelineLabel: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
lineHeight: 15,
},
footerCard: {
@@ -272,11 +272,11 @@ const styles = StyleSheet.create({
},
footerLabel: {
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
footerValue: {
fontSize: 12,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
});
+13 -12
View File
@@ -18,6 +18,7 @@ import Animated, {
import type { BankCard } from '../../../types/app';
import { getCardStatusLabel } from '../utils/cardCopy';
import { fontFamily } from '../../../theme';
interface CardItemProps {
card: BankCard;
@@ -179,13 +180,13 @@ const styles = StyleSheet.create({
brandName: {
color: 'white',
fontSize: 18,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
cardMeta: {
color: 'rgba(255,255,255,0.72)',
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 1.1,
marginTop: 2,
},
@@ -206,7 +207,7 @@ const styles = StyleSheet.create({
statusBadgeText: {
color: 'white',
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
centerBlock: {
gap: 10,
@@ -214,20 +215,20 @@ const styles = StyleSheet.create({
cardName: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
},
cardNumber: {
color: 'white',
fontSize: 28,
fontWeight: '900',
fontFamily: fontFamily.bold,
letterSpacing: 2,
},
holderName: {
color: 'rgba(255,255,255,0.92)',
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
letterSpacing: 0.6,
},
footerRow: {
@@ -239,13 +240,13 @@ const styles = StyleSheet.create({
footerLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 9,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
marginTop: 4,
},
flipHint: {
@@ -256,7 +257,7 @@ const styles = StyleSheet.create({
flipText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
magneticStrip: {
height: 42,
@@ -280,13 +281,13 @@ const styles = StyleSheet.create({
backLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 10,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: 0.9,
},
backValue: {
color: 'white',
fontSize: 14,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
backFooter: {
gap: 6,
@@ -294,7 +295,7 @@ const styles = StyleSheet.create({
backSupport: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -13,7 +13,7 @@ import Animated, {
} from 'react-native-reanimated';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface ExpandableSectionProps {
title: string;
@@ -120,7 +120,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
subtitle: {
@@ -134,7 +134,7 @@ const styles = StyleSheet.create({
},
badgeText: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
body: {
gap: 16,
+4 -4
View File
@@ -14,7 +14,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface LimitSliderProps {
label: string;
@@ -269,7 +269,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
helperText: {
fontSize: 12,
@@ -277,7 +277,7 @@ const styles = StyleSheet.create({
},
value: {
fontSize: 13,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
sliderRow: {
flexDirection: 'row',
@@ -324,7 +324,7 @@ const styles = StyleSheet.create({
},
scaleText: {
fontSize: 11,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
});
@@ -12,7 +12,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { useTheme, fontFamily } from '../../../theme';
interface ToggleSwitchProps {
label: string;
@@ -105,7 +105,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
description: {
fontSize: 12,

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