Update CBS Mobile App codebase
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -8,6 +8,7 @@ export const QUERY_TAGS = [
|
||||
'Payments',
|
||||
'Deposits',
|
||||
'Services',
|
||||
'ChequeBooks',
|
||||
'Loans',
|
||||
'LoanActive',
|
||||
'LoanApplications',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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: {} });
|
||||
},
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 & 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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'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'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'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,
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user