Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID): OTP enrollment, cheque submit, saved cheques, detail and stop payment. Full flow passed on the phone against mobile-banking-backend.lincpay. - Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on Home and the Statement screen; dated PDF export with the password rule. - Onboarding slides and splash screen, Inter font, shared Icon and TypewriterText components, nearby branches via OpenStreetMap. - Every feature switched on; the 14 Oct switch set is kept in featureConfig.stage-2026-10-14.ts. Loans run on demo data until the backend has /loans routes. - npm run android wraps run-android for shells spawned from VS Code. - OpenProject work packages updated to 14-10-2026 status, with screenshots of Positive Pay, statement, loans and cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5fbd13ba12
commit
f960a119e8
@@ -7,6 +7,7 @@ import { transferReducer } from '../modules/transfer/store/transferSlice';
|
||||
import { paymentsReducer } from '../modules/payments/store/paymentsSlice';
|
||||
import { depositReducer } from '../modules/deposit/store/depositSlice';
|
||||
import { serviceReducer } from '../modules/services/store/serviceSlice';
|
||||
import { ppsReducer } from '../modules/services/store/ppsSlice';
|
||||
import { loanReducer } from '../modules/loan/store/loanSlice';
|
||||
import { insuranceReducer } from '../modules/insurance/store/insuranceSlice';
|
||||
import { cardsReducer } from '../modules/cards/store/cardsSlice';
|
||||
@@ -26,6 +27,7 @@ const appReducer = combineReducers({
|
||||
payments: paymentsReducer,
|
||||
deposit: depositReducer,
|
||||
service: serviceReducer,
|
||||
pps: ppsReducer,
|
||||
loan: loanReducer,
|
||||
insurance: insuranceReducer,
|
||||
cards: cardsReducer,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import type { CbsAccount } from '../../types/cbs';
|
||||
import { useTheme, textStyles } from '../../theme';
|
||||
@@ -74,30 +74,33 @@ const AccountCardComponent = ({
|
||||
const showFull = revealed && canRevealNumber;
|
||||
const displayNumber = showFull
|
||||
? account.accountNumber
|
||||
: `XXXX ${account.accountNumber.slice(-4)}`;
|
||||
: `•••• ${account.accountNumber.slice(-4)}`;
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={theme.colors.brandGradient}
|
||||
colors={theme.colors.heroGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
|
||||
>
|
||||
{/* Two overlapping translucent discs — the card's only ornament. Kept at
|
||||
the on-brand surface tier so the text above them stays legible. */}
|
||||
<View style={[styles.brandAccent, { backgroundColor: theme.colors.onBrandSurface }]} />
|
||||
<View style={[styles.brandAccentLow, { 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} />
|
||||
<Icon name="business" size={20} color={theme.colors.onBrandPrimary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.bankName, { color: theme.colors.onBrandPrimary }]}>
|
||||
CBS Bank
|
||||
</Text>
|
||||
<Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}>
|
||||
{accountTypeLabel(account.accountType).toUpperCase()}
|
||||
{accountTypeLabel(account.accountType)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -116,7 +119,7 @@ const AccountCardComponent = ({
|
||||
|
||||
<View style={styles.mainContent}>
|
||||
<Text style={[styles.numberLabel, { color: theme.colors.onBrandSecondary }]}>
|
||||
ACCOUNT NUMBER
|
||||
Account number
|
||||
</Text>
|
||||
<View style={styles.numberRow}>
|
||||
<Text
|
||||
@@ -133,7 +136,7 @@ const AccountCardComponent = ({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showFull ? 'Hide account number' : 'Show account number'}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={showFull ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
@@ -165,7 +168,7 @@ const AccountCardComponent = ({
|
||||
</View>
|
||||
) : balanceError ? (
|
||||
<View style={styles.balanceRow}>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="alert-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
@@ -185,7 +188,7 @@ const AccountCardComponent = ({
|
||||
<View style={styles.balanceRow}>
|
||||
<View style={styles.balanceCopy}>
|
||||
<Text style={[styles.balanceLabel, { color: theme.colors.onBrandMuted }]}>
|
||||
AVAILABLE BALANCE
|
||||
Available balance
|
||||
</Text>
|
||||
<Text style={[styles.balanceValue, { color: theme.colors.onBrandPrimary }]}>
|
||||
{formatCurrency(balance)}
|
||||
@@ -193,15 +196,15 @@ const AccountCardComponent = ({
|
||||
</View>
|
||||
{/* A balance is only true at the instant it was fetched, so the
|
||||
way to get a fresh one stays on screen next to the figure. */}
|
||||
<Ionicons name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
|
||||
<Icon name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.balanceRow}>
|
||||
<Ionicons name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
|
||||
<Icon name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
|
||||
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
|
||||
Check balance
|
||||
</Text>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={18}
|
||||
color={theme.colors.onBrandSecondary}
|
||||
@@ -214,7 +217,7 @@ const AccountCardComponent = ({
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.footerItem}>
|
||||
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
|
||||
{accountHolder ? 'ACCOUNT HOLDER' : 'IFSC'}
|
||||
{accountHolder ? 'Account holder' : 'IFSC'}
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
|
||||
{accountHolder ?? account.ifsc}
|
||||
@@ -235,22 +238,30 @@ const AccountCardComponent = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 24,
|
||||
gap: 32,
|
||||
padding: 22,
|
||||
gap: 26,
|
||||
position: 'relative',
|
||||
minHeight: 260,
|
||||
minHeight: 220,
|
||||
},
|
||||
gradientCard: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
brandAccent: {
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
top: -60,
|
||||
right: -40,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
},
|
||||
brandAccentLow: {
|
||||
position: 'absolute',
|
||||
bottom: -90,
|
||||
right: 40,
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 90,
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@@ -273,7 +284,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cardType: {
|
||||
...textStyles.eyebrow,
|
||||
fontSize: 10,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
@@ -282,27 +292,27 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
statusBadgeText: {
|
||||
...textStyles.eyebrow,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 11,
|
||||
},
|
||||
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.
|
||||
// An account number, so it is set with tabular digits — a customer reads
|
||||
// these back a group at a time.
|
||||
numberValue: {
|
||||
...textStyles.code,
|
||||
fontFamily: textStyles.h2.fontFamily,
|
||||
flex: 1,
|
||||
fontSize: 22,
|
||||
letterSpacing: 1.5,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
revealButton: {
|
||||
marginLeft: 12,
|
||||
@@ -330,14 +340,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
balanceLabel: {
|
||||
...textStyles.eyebrow,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Mono, like every other figure in the app — the theme's own reason is that
|
||||
// proportional digits reflow as a number changes.
|
||||
// Tabular, like every other figure in the app — proportional digits reflow
|
||||
// as a number changes.
|
||||
balanceValue: {
|
||||
...textStyles.moneyLarge,
|
||||
fontSize: 22,
|
||||
...textStyles.balance,
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
},
|
||||
balanceAction: {
|
||||
...textStyles.bodyStrong,
|
||||
@@ -363,8 +372,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footerLabel: {
|
||||
...textStyles.eyebrow,
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
fontSize: 11,
|
||||
},
|
||||
footerValue: {
|
||||
...textStyles.bodyStrong,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../theme';
|
||||
|
||||
@@ -30,23 +30,29 @@ const AppHeaderComponent = ({
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.leading}>
|
||||
{/* A bare arrow, not a bordered chip: the header is a single quiet
|
||||
line — arrow, then title — and the press state is the only fill. */}
|
||||
{shouldShowBackButton ? (
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={[
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => [
|
||||
styles.backButton,
|
||||
{
|
||||
backgroundColor: theme.colors.cardGlass,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
backgroundColor: pressed ? theme.colors.surfaceMuted : 'transparent',
|
||||
borderRadius: theme.radius.pill,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.textPrimary} />
|
||||
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
<Text numberOfLines={1} style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{title}
|
||||
</Text>
|
||||
{/* {subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
|
||||
) : null} */}
|
||||
@@ -74,21 +80,22 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
},
|
||||
// Sentence case, as the screen names it. The uppercase tracked title this
|
||||
// replaced shouted every screen name at the same volume.
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 20,
|
||||
fontSize: 18,
|
||||
fontFamily: fontFamily.semibold,
|
||||
letterSpacing: -0.3,
|
||||
lineHeight: 24,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
backButton: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderWidth: 1,
|
||||
width: 40,
|
||||
height: 40,
|
||||
marginLeft: -6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import { SegmentedControl, type SegmentedControlOption } from '../SegmentedControl';
|
||||
import { textStyles, useTheme, useThemeMode, type ThemeMode } from '../../theme';
|
||||
@@ -46,7 +46,7 @@ export const AppearanceSetting = () => {
|
||||
*/}
|
||||
{mode === 'system' ? (
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={resolved === 'dark' ? 'moon-outline' : 'sunny-outline'}
|
||||
size={14}
|
||||
color={theme.colors.textMuted}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -40,12 +38,24 @@ export const BottomSheet = ({ visible, title, onClose, children }: BottomSheetPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.flex}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
|
||||
>
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent
|
||||
statusBarTranslucent
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{/*
|
||||
Self-correcting on both platforms. The keyboard is measured in screen
|
||||
coordinates and this view's frame is relative to the modal's window,
|
||||
which `statusBarTranslucent` starts at the very top of the screen — so
|
||||
the two line up and no offset is needed. If the window was resized for
|
||||
the keyboard (Android's adjustResize), the frame already ends at the
|
||||
keyboard and the padding comes out as zero; if it was not (iOS, or an
|
||||
edge-to-edge Android 15 window), it pads by exactly the overlap.
|
||||
`height` on Android shrank a window that had often already shrunk.
|
||||
*/}
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.flex}>
|
||||
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
||||
<View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }]}>
|
||||
<TouchableWithoutFeedback onPress={() => {}}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ScrollView,
|
||||
PanResponder,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
import { useTheme, fontFamily } from '../../theme';
|
||||
@@ -208,23 +208,23 @@ export const DatePicker = ({
|
||||
<>
|
||||
{/* Header: tappable month & year */}
|
||||
<View style={styles.calendarHeader}>
|
||||
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.primary} />
|
||||
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
|
||||
<Icon name="chevron-back" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.monthYearWrap}>
|
||||
<Pressable onPress={() => setPickerMode('months')} style={styles.headerTappable}>
|
||||
<Text style={[styles.monthText, { color: theme.colors.primary }]}>{MONTHS[viewMonth]}</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
|
||||
<Icon name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleOpenYearPicker} style={styles.headerTappable}>
|
||||
<Text style={[styles.yearText, { color: theme.colors.textMuted }]}>{viewYear}</Text>
|
||||
<Ionicons name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
|
||||
<Icon name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.primary} />
|
||||
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
|
||||
<Icon name="chevron-forward" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -297,7 +297,7 @@ export const DatePicker = ({
|
||||
setViewYear(today.getFullYear());
|
||||
handleSelect(today.getDate());
|
||||
}}
|
||||
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
|
||||
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}
|
||||
>
|
||||
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>Today</Text>
|
||||
</Pressable>
|
||||
@@ -310,7 +310,7 @@ export const DatePicker = ({
|
||||
<>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
||||
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Month</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
@@ -326,7 +326,7 @@ export const DatePicker = ({
|
||||
style={[
|
||||
styles.monthCell,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.primary : `${theme.colors.primary}08`,
|
||||
backgroundColor: isActive ? theme.colors.primary : theme.tint(theme.colors.primary, 0.03),
|
||||
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
@@ -351,7 +351,7 @@ export const DatePicker = ({
|
||||
<>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
||||
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Year</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
@@ -372,7 +372,7 @@ export const DatePicker = ({
|
||||
style={[
|
||||
styles.yearRow,
|
||||
{
|
||||
backgroundColor: isActive ? `${theme.colors.primary}12` : 'transparent',
|
||||
backgroundColor: isActive ? theme.tint(theme.colors.primary, 0.07) : 'transparent',
|
||||
borderColor: isActive ? theme.colors.primary : 'transparent',
|
||||
},
|
||||
]}
|
||||
@@ -389,7 +389,7 @@ export const DatePicker = ({
|
||||
{year}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<Ionicons name="checkmark" size={20} color={theme.colors.primary} />
|
||||
<Icon name="checkmark" size={20} color={theme.colors.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -413,11 +413,11 @@ export const DatePicker = ({
|
||||
styles.inputWrapper,
|
||||
{
|
||||
borderColor: error ? theme.colors.error : theme.colors.cardBorder,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
|
||||
<Icon name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
|
||||
<Text
|
||||
style={[
|
||||
styles.inputText,
|
||||
@@ -426,7 +426,7 @@ export const DatePicker = ({
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
<Icon name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import React, { memo } from 'react';
|
||||
import type { StyleProp, ViewStyle } from 'react-native';
|
||||
import { HugeiconsIcon } from '@hugeicons/react-native';
|
||||
|
||||
import AccessibilityIcon from '@hugeicons/core-free-icons/AccessibilityIcon';
|
||||
import Add01Icon from '@hugeicons/core-free-icons/Add01Icon';
|
||||
import AddCircleIcon from '@hugeicons/core-free-icons/AddCircleIcon';
|
||||
import Airplane01Icon from '@hugeicons/core-free-icons/Airplane01Icon';
|
||||
import AlarmClockIcon from '@hugeicons/core-free-icons/AlarmClockIcon';
|
||||
import Album01Icon from '@hugeicons/core-free-icons/Album01Icon';
|
||||
import Alert02Icon from '@hugeicons/core-free-icons/Alert02Icon';
|
||||
import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
|
||||
import Analytics01Icon from '@hugeicons/core-free-icons/Analytics01Icon';
|
||||
import ArrowDataTransferHorizontalIcon from '@hugeicons/core-free-icons/ArrowDataTransferHorizontalIcon';
|
||||
import ArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';
|
||||
import ArrowDown02Icon from '@hugeicons/core-free-icons/ArrowDown02Icon';
|
||||
import ArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';
|
||||
import ArrowLeft02Icon from '@hugeicons/core-free-icons/ArrowLeft02Icon';
|
||||
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
|
||||
import ArrowRight02Icon from '@hugeicons/core-free-icons/ArrowRight02Icon';
|
||||
import ArrowUp01Icon from '@hugeicons/core-free-icons/ArrowUp01Icon';
|
||||
import ArrowUp02Icon from '@hugeicons/core-free-icons/ArrowUp02Icon';
|
||||
import ArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';
|
||||
import Attachment01Icon from '@hugeicons/core-free-icons/Attachment01Icon';
|
||||
import Award01Icon from '@hugeicons/core-free-icons/Award01Icon';
|
||||
import BankIcon from '@hugeicons/core-free-icons/BankIcon';
|
||||
import BarcodeIcon from '@hugeicons/core-free-icons/BarcodeIcon';
|
||||
import Book02Icon from '@hugeicons/core-free-icons/Book02Icon';
|
||||
import Bookmark02Icon from '@hugeicons/core-free-icons/Bookmark02Icon';
|
||||
import Briefcase01Icon from '@hugeicons/core-free-icons/Briefcase01Icon';
|
||||
import BubbleChatIcon from '@hugeicons/core-free-icons/BubbleChatIcon';
|
||||
import Calculator01Icon from '@hugeicons/core-free-icons/Calculator01Icon';
|
||||
import Calendar03Icon from '@hugeicons/core-free-icons/Calendar03Icon';
|
||||
import Call02Icon from '@hugeicons/core-free-icons/Call02Icon';
|
||||
import Camera01Icon from '@hugeicons/core-free-icons/Camera01Icon';
|
||||
import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
|
||||
import CancelCircleIcon from '@hugeicons/core-free-icons/CancelCircleIcon';
|
||||
import Car01Icon from '@hugeicons/core-free-icons/Car01Icon';
|
||||
import Car02Icon from '@hugeicons/core-free-icons/Car02Icon';
|
||||
import ChartDecreaseIcon from '@hugeicons/core-free-icons/ChartDecreaseIcon';
|
||||
import ChartHistogramIcon from '@hugeicons/core-free-icons/ChartHistogramIcon';
|
||||
import ChartIncreaseIcon from '@hugeicons/core-free-icons/ChartIncreaseIcon';
|
||||
import Chatting01Icon from '@hugeicons/core-free-icons/Chatting01Icon';
|
||||
import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
|
||||
import CheckmarkSquare02Icon from '@hugeicons/core-free-icons/CheckmarkSquare02Icon';
|
||||
import CircleIcon from '@hugeicons/core-free-icons/CircleIcon';
|
||||
import Clock01Icon from '@hugeicons/core-free-icons/Clock01Icon';
|
||||
import CloudDownloadIcon from '@hugeicons/core-free-icons/CloudDownloadIcon';
|
||||
import CloudSlowWindIcon from '@hugeicons/core-free-icons/CloudSlowWindIcon';
|
||||
import CloudUploadIcon from '@hugeicons/core-free-icons/CloudUploadIcon';
|
||||
import CreditCardIcon from '@hugeicons/core-free-icons/CreditCardIcon';
|
||||
import CubeIcon from '@hugeicons/core-free-icons/CubeIcon';
|
||||
import CustomerSupportIcon from '@hugeicons/core-free-icons/CustomerSupportIcon';
|
||||
import DashboardSquare01Icon from '@hugeicons/core-free-icons/DashboardSquare01Icon';
|
||||
import Delete02Icon from '@hugeicons/core-free-icons/Delete02Icon';
|
||||
import DiamondIcon from '@hugeicons/core-free-icons/DiamondIcon';
|
||||
import Download04Icon from '@hugeicons/core-free-icons/Download04Icon';
|
||||
import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
|
||||
import File01Icon from '@hugeicons/core-free-icons/File01Icon';
|
||||
import File02Icon from '@hugeicons/core-free-icons/File02Icon';
|
||||
import Files02Icon from '@hugeicons/core-free-icons/Files02Icon';
|
||||
import FilterHorizontalIcon from '@hugeicons/core-free-icons/FilterHorizontalIcon';
|
||||
import FingerPrintIcon from '@hugeicons/core-free-icons/FingerPrintIcon';
|
||||
import FirstAidKitIcon from '@hugeicons/core-free-icons/FirstAidKitIcon';
|
||||
import FlashIcon from '@hugeicons/core-free-icons/FlashIcon';
|
||||
import FloppyDiskIcon from '@hugeicons/core-free-icons/FloppyDiskIcon';
|
||||
import FolderOpenIcon from '@hugeicons/core-free-icons/FolderOpenIcon';
|
||||
import GiftIcon from '@hugeicons/core-free-icons/GiftIcon';
|
||||
import GitCompareIcon from '@hugeicons/core-free-icons/GitCompareIcon';
|
||||
import GitPullRequestIcon from '@hugeicons/core-free-icons/GitPullRequestIcon';
|
||||
import Globe02Icon from '@hugeicons/core-free-icons/Globe02Icon';
|
||||
import GpsSignal01Icon from '@hugeicons/core-free-icons/GpsSignal01Icon';
|
||||
import GraduationCapIcon from '@hugeicons/core-free-icons/GraduationCapIcon';
|
||||
import GridIcon from '@hugeicons/core-free-icons/GridIcon';
|
||||
import HandPrayerIcon from '@hugeicons/core-free-icons/HandPrayerIcon';
|
||||
import HealthIcon from '@hugeicons/core-free-icons/HealthIcon';
|
||||
import HelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';
|
||||
import HierarchyIcon from '@hugeicons/core-free-icons/HierarchyIcon';
|
||||
import Home01Icon from '@hugeicons/core-free-icons/Home01Icon';
|
||||
import HourglassIcon from '@hugeicons/core-free-icons/HourglassIcon';
|
||||
import IdIcon from '@hugeicons/core-free-icons/IdIcon';
|
||||
import Image01Icon from '@hugeicons/core-free-icons/Image01Icon';
|
||||
import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
|
||||
import Invoice01Icon from '@hugeicons/core-free-icons/Invoice01Icon';
|
||||
import Key01Icon from '@hugeicons/core-free-icons/Key01Icon';
|
||||
import Layers01Icon from '@hugeicons/core-free-icons/Layers01Icon';
|
||||
import Leaf01Icon from '@hugeicons/core-free-icons/Leaf01Icon';
|
||||
import Link01Icon from '@hugeicons/core-free-icons/Link01Icon';
|
||||
import Location01Icon from '@hugeicons/core-free-icons/Location01Icon';
|
||||
import Logout03Icon from '@hugeicons/core-free-icons/Logout03Icon';
|
||||
import Mail01Icon from '@hugeicons/core-free-icons/Mail01Icon';
|
||||
import MapsIcon from '@hugeicons/core-free-icons/MapsIcon';
|
||||
import Megaphone01Icon from '@hugeicons/core-free-icons/Megaphone01Icon';
|
||||
import Menu01Icon from '@hugeicons/core-free-icons/Menu01Icon';
|
||||
import Message01Icon from '@hugeicons/core-free-icons/Message01Icon';
|
||||
import MinusSignIcon from '@hugeicons/core-free-icons/MinusSignIcon';
|
||||
import Money03Icon from '@hugeicons/core-free-icons/Money03Icon';
|
||||
import Moon02Icon from '@hugeicons/core-free-icons/Moon02Icon';
|
||||
import Navigation03Icon from '@hugeicons/core-free-icons/Navigation03Icon';
|
||||
import News01Icon from '@hugeicons/core-free-icons/News01Icon';
|
||||
import Notification03Icon from '@hugeicons/core-free-icons/Notification03Icon';
|
||||
import PencilEdit02Icon from '@hugeicons/core-free-icons/PencilEdit02Icon';
|
||||
import PieChartIcon from '@hugeicons/core-free-icons/PieChartIcon';
|
||||
import Pin02Icon from '@hugeicons/core-free-icons/Pin02Icon';
|
||||
import PlayCircleIcon from '@hugeicons/core-free-icons/PlayCircleIcon';
|
||||
import PrinterIcon from '@hugeicons/core-free-icons/PrinterIcon';
|
||||
import PulseRectangle01Icon from '@hugeicons/core-free-icons/PulseRectangle01Icon';
|
||||
import QrCodeIcon from '@hugeicons/core-free-icons/QrCodeIcon';
|
||||
import Radio01Icon from '@hugeicons/core-free-icons/Radio01Icon';
|
||||
import RecordIcon from '@hugeicons/core-free-icons/RecordIcon';
|
||||
import RefreshIcon from '@hugeicons/core-free-icons/RefreshIcon';
|
||||
import RepeatIcon from '@hugeicons/core-free-icons/RepeatIcon';
|
||||
import Sad01Icon from '@hugeicons/core-free-icons/Sad01Icon';
|
||||
import ScanIcon from '@hugeicons/core-free-icons/ScanIcon';
|
||||
import Search01Icon from '@hugeicons/core-free-icons/Search01Icon';
|
||||
import SecurityCheckIcon from '@hugeicons/core-free-icons/SecurityCheckIcon';
|
||||
import SentIcon from '@hugeicons/core-free-icons/SentIcon';
|
||||
import Settings01Icon from '@hugeicons/core-free-icons/Settings01Icon';
|
||||
import Share01Icon from '@hugeicons/core-free-icons/Share01Icon';
|
||||
import Share08Icon from '@hugeicons/core-free-icons/Share08Icon';
|
||||
import Shield01Icon from '@hugeicons/core-free-icons/Shield01Icon';
|
||||
import SmartPhone01Icon from '@hugeicons/core-free-icons/SmartPhone01Icon';
|
||||
import SnowIcon from '@hugeicons/core-free-icons/SnowIcon';
|
||||
import SourceCodeIcon from '@hugeicons/core-free-icons/SourceCodeIcon';
|
||||
import SparklesIcon from '@hugeicons/core-free-icons/SparklesIcon';
|
||||
import SquareIcon from '@hugeicons/core-free-icons/SquareIcon';
|
||||
import SquareLock02Icon from '@hugeicons/core-free-icons/SquareLock02Icon';
|
||||
import SquareUnlock02Icon from '@hugeicons/core-free-icons/SquareUnlock02Icon';
|
||||
import StarIcon from '@hugeicons/core-free-icons/StarIcon';
|
||||
import StopCircleIcon from '@hugeicons/core-free-icons/StopCircleIcon';
|
||||
import StopIcon from '@hugeicons/core-free-icons/StopIcon';
|
||||
import Sun03Icon from '@hugeicons/core-free-icons/Sun03Icon';
|
||||
import Tag01Icon from '@hugeicons/core-free-icons/Tag01Icon';
|
||||
import TextFontIcon from '@hugeicons/core-free-icons/TextFontIcon';
|
||||
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
|
||||
import TickDouble02Icon from '@hugeicons/core-free-icons/TickDouble02Icon';
|
||||
import Ticket01Icon from '@hugeicons/core-free-icons/Ticket01Icon';
|
||||
import ToggleOnIcon from '@hugeicons/core-free-icons/ToggleOnIcon';
|
||||
import TranslateIcon from '@hugeicons/core-free-icons/TranslateIcon';
|
||||
import UmbrellaIcon from '@hugeicons/core-free-icons/UmbrellaIcon';
|
||||
import UserAdd01Icon from '@hugeicons/core-free-icons/UserAdd01Icon';
|
||||
import UserCircleIcon from '@hugeicons/core-free-icons/UserCircleIcon';
|
||||
import UserGroupIcon from '@hugeicons/core-free-icons/UserGroupIcon';
|
||||
import UserIcon from '@hugeicons/core-free-icons/UserIcon';
|
||||
import Video01Icon from '@hugeicons/core-free-icons/Video01Icon';
|
||||
import ViewIcon from '@hugeicons/core-free-icons/ViewIcon';
|
||||
import ViewOffSlashIcon from '@hugeicons/core-free-icons/ViewOffSlashIcon';
|
||||
import Wallet01Icon from '@hugeicons/core-free-icons/Wallet01Icon';
|
||||
import Wifi01Icon from '@hugeicons/core-free-icons/Wifi01Icon';
|
||||
|
||||
/**
|
||||
* The app's one icon component — Hugeicons, "stroke rounded" style.
|
||||
*
|
||||
* ── THE LOOK ────────────────────────────────────────────────────────────────
|
||||
* Single-weight line icons on a 24pt grid with fully rounded caps, joins and
|
||||
* corners: a soft, friendly outline that sits well next to Inter. No fills,
|
||||
* no second tone — the clean, light look of a modern banking app.
|
||||
*
|
||||
* Free and MIT-licensed (@hugeicons/core-free-icons), drawn with
|
||||
* react-native-svg, which the app already ships. Each glyph is imported on its
|
||||
* own, so only the ones listed here are bundled — not all 4,000+.
|
||||
*
|
||||
* ── NAMES ───────────────────────────────────────────────────────────────────
|
||||
* The component takes the Ionicons-style names the app already used, so call
|
||||
* sites did not change. The name only tunes the stroke now:
|
||||
*
|
||||
* 'wallet-outline' 1.5 the default line
|
||||
* 'wallet' 2.0 Ionicons' solid variant meant "emphasis" — the
|
||||
* active tab, a status mark — so it draws bolder
|
||||
* 'chevron-forward' 1.8 bare lines (arrows, carets, ticks, crosses) a
|
||||
* touch heavier so they hold their own
|
||||
*
|
||||
* Below 18pt every stroke is nudged up, since a 1.5 line at 14pt turns faint.
|
||||
* Pass `strokeWidth` to override.
|
||||
*/
|
||||
|
||||
const GLYPHS = {
|
||||
'accessibility': AccessibilityIcon,
|
||||
'add': Add01Icon,
|
||||
'add-circle': AddCircleIcon,
|
||||
'airplane': Airplane01Icon,
|
||||
'alarm': AlarmClockIcon,
|
||||
'albums': Album01Icon,
|
||||
'alert': AlertCircleIcon,
|
||||
'alert-circle': AlertCircleIcon,
|
||||
'analytics': Analytics01Icon,
|
||||
'arrow-back': ArrowLeft02Icon,
|
||||
'arrow-down': ArrowDown02Icon,
|
||||
'arrow-forward': ArrowRight02Icon,
|
||||
'arrow-up': ArrowUp02Icon,
|
||||
'arrow-up-circle': ArrowUpRight01Icon,
|
||||
'barcode': BarcodeIcon,
|
||||
'book': Book02Icon,
|
||||
'bookmark': Bookmark02Icon,
|
||||
'briefcase': Briefcase01Icon,
|
||||
'business': BankIcon,
|
||||
'calculator': Calculator01Icon,
|
||||
'calendar': Calendar03Icon,
|
||||
'call': Call02Icon,
|
||||
'camera': Camera01Icon,
|
||||
'car': Car01Icon,
|
||||
'car-sport': Car02Icon,
|
||||
'card': CreditCardIcon,
|
||||
'cash': Money03Icon,
|
||||
'chatbox': Message01Icon,
|
||||
'chatbubble': BubbleChatIcon,
|
||||
'chatbubble-ellipses': BubbleChatIcon,
|
||||
'chatbubbles': Chatting01Icon,
|
||||
'checkbox': CheckmarkSquare02Icon,
|
||||
'checkmark': Tick02Icon,
|
||||
'checkmark-circle': CheckmarkCircle02Icon,
|
||||
'checkmark-done': TickDouble02Icon,
|
||||
'chevron-back': ArrowLeft01Icon,
|
||||
'chevron-down': ArrowDown01Icon,
|
||||
'chevron-forward': ArrowRight01Icon,
|
||||
'chevron-up': ArrowUp01Icon,
|
||||
'close': Cancel01Icon,
|
||||
'close-circle': CancelCircleIcon,
|
||||
'cloud-download': CloudDownloadIcon,
|
||||
'cloud-offline': CloudSlowWindIcon,
|
||||
'cloud-upload': CloudUploadIcon,
|
||||
'code': SourceCodeIcon,
|
||||
'create': PencilEdit02Icon,
|
||||
'cube': CubeIcon,
|
||||
'diamond': DiamondIcon,
|
||||
'document': File01Icon,
|
||||
'document-attach': Attachment01Icon,
|
||||
'document-text': File02Icon,
|
||||
'documents': Files02Icon,
|
||||
'download': Download04Icon,
|
||||
'ellipse': CircleIcon,
|
||||
'eye': ViewIcon,
|
||||
'eye-off': ViewOffSlashIcon,
|
||||
'finger-print': FingerPrintIcon,
|
||||
'flash': FlashIcon,
|
||||
'folder-open': FolderOpenIcon,
|
||||
'gift': GiftIcon,
|
||||
'git-compare': GitCompareIcon,
|
||||
'git-network': HierarchyIcon,
|
||||
'git-pull-request': GitPullRequestIcon,
|
||||
'globe': Globe02Icon,
|
||||
'grid': DashboardSquare01Icon,
|
||||
'hand-left': HandPrayerIcon,
|
||||
'heart': FavouriteIcon,
|
||||
'heart-circle': HealthIcon,
|
||||
'help': HelpCircleIcon,
|
||||
'help-buoy': CustomerSupportIcon,
|
||||
'help-circle': HelpCircleIcon,
|
||||
'home': Home01Icon,
|
||||
'hourglass': HourglassIcon,
|
||||
'id-card': IdIcon,
|
||||
'image': Image01Icon,
|
||||
'information-circle': InformationCircleIcon,
|
||||
'key': Key01Icon,
|
||||
'keypad': GridIcon,
|
||||
'language': TranslateIcon,
|
||||
'layers': Layers01Icon,
|
||||
'leaf': Leaf01Icon,
|
||||
'link': Link01Icon,
|
||||
'list': Menu01Icon,
|
||||
'locate': GpsSignal01Icon,
|
||||
'location': Location01Icon,
|
||||
'lock-closed': SquareLock02Icon,
|
||||
'lock-open': SquareUnlock02Icon,
|
||||
'log-out': Logout03Icon,
|
||||
'mail': Mail01Icon,
|
||||
'map': MapsIcon,
|
||||
'medkit': FirstAidKitIcon,
|
||||
'megaphone': Megaphone01Icon,
|
||||
'moon': Moon02Icon,
|
||||
'navigate': Navigation03Icon,
|
||||
'notifications': Notification03Icon,
|
||||
'options': FilterHorizontalIcon,
|
||||
'paper-plane': SentIcon,
|
||||
'people': UserGroupIcon,
|
||||
'person': UserIcon,
|
||||
'person-add': UserAdd01Icon,
|
||||
'person-circle': UserCircleIcon,
|
||||
'phone-portrait': SmartPhone01Icon,
|
||||
'pie-chart': PieChartIcon,
|
||||
'pin': Pin02Icon,
|
||||
'play-circle': PlayCircleIcon,
|
||||
'pricetag': Tag01Icon,
|
||||
'print': PrinterIcon,
|
||||
'pulse': PulseRectangle01Icon,
|
||||
'qr-code': QrCodeIcon,
|
||||
'radio': Radio01Icon,
|
||||
'radio-button-off': CircleIcon,
|
||||
'radio-button-on': RecordIcon,
|
||||
'reader': News01Icon,
|
||||
'receipt': Invoice01Icon,
|
||||
'refresh': RefreshIcon,
|
||||
'refresh-circle': RefreshIcon,
|
||||
'remove': MinusSignIcon,
|
||||
'repeat': RepeatIcon,
|
||||
'ribbon': Award01Icon,
|
||||
'sad': Sad01Icon,
|
||||
'save': FloppyDiskIcon,
|
||||
'scan': ScanIcon,
|
||||
'school': GraduationCapIcon,
|
||||
'search': Search01Icon,
|
||||
'send': SentIcon,
|
||||
'settings': Settings01Icon,
|
||||
'share': Share08Icon,
|
||||
'share-social': Share01Icon,
|
||||
'shield': Shield01Icon,
|
||||
'shield-checkmark': SecurityCheckIcon,
|
||||
'shield-half': Shield01Icon,
|
||||
'snow': SnowIcon,
|
||||
'sparkles': SparklesIcon,
|
||||
'square': SquareIcon,
|
||||
'star': StarIcon,
|
||||
'stats-chart': ChartHistogramIcon,
|
||||
'stop': StopIcon,
|
||||
'stop-circle': StopCircleIcon,
|
||||
'sunny': Sun03Icon,
|
||||
'swap-horizontal': ArrowDataTransferHorizontalIcon,
|
||||
'sync': RepeatIcon,
|
||||
'text': TextFontIcon,
|
||||
'ticket': Ticket01Icon,
|
||||
'time': Clock01Icon,
|
||||
'toggle': ToggleOnIcon,
|
||||
'trash': Delete02Icon,
|
||||
'trending-down': ChartDecreaseIcon,
|
||||
'trending-up': ChartIncreaseIcon,
|
||||
'umbrella': UmbrellaIcon,
|
||||
'videocam': Video01Icon,
|
||||
'wallet': Wallet01Icon,
|
||||
'warning': Alert02Icon,
|
||||
'wifi': Wifi01Icon,
|
||||
} as const;
|
||||
|
||||
type BaseName = keyof typeof GLYPHS;
|
||||
|
||||
/** Every name the app may pass: each glyph, and its `-outline` variant. */
|
||||
export type IconName = BaseName | `${BaseName}-outline`;
|
||||
|
||||
const LINE_GLYPHS: ReadonlySet<string> = new Set([
|
||||
'add',
|
||||
'remove',
|
||||
'arrow-back',
|
||||
'arrow-down',
|
||||
'arrow-forward',
|
||||
'arrow-up',
|
||||
'chevron-back',
|
||||
'chevron-down',
|
||||
'chevron-forward',
|
||||
'chevron-up',
|
||||
'checkmark',
|
||||
'checkmark-done',
|
||||
'close',
|
||||
'list',
|
||||
'link',
|
||||
'swap-horizontal',
|
||||
]);
|
||||
|
||||
interface AppIconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
/** Overrides the stroke the name would choose. */
|
||||
strokeWidth?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const strokeFor = (name: string, base: string, size: number) => {
|
||||
const stroke = LINE_GLYPHS.has(base) ? 1.8 : name.endsWith('-outline') ? 1.5 : 2;
|
||||
|
||||
return size < 18 ? stroke + 0.3 : stroke;
|
||||
};
|
||||
|
||||
const IconComponent = ({ name, size = 24, color, strokeWidth, style, testID }: AppIconProps) => {
|
||||
const base = name.replace(/-outline$/, '') as BaseName;
|
||||
const glyph = GLYPHS[base];
|
||||
|
||||
if (!glyph) {
|
||||
if (__DEV__) {
|
||||
console.warn(`Icon: no glyph mapped for "${name}"`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={glyph}
|
||||
size={size}
|
||||
color={color}
|
||||
strokeWidth={strokeWidth ?? strokeFor(name, base, size)}
|
||||
style={style}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Icon = memo(IconComponent);
|
||||
export default Icon;
|
||||
@@ -1,12 +1,12 @@
|
||||
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 { Icon } from '../Icon';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
type InfoBannerTone = 'info' | 'warning' | 'danger' | 'success' | 'brand';
|
||||
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IoniconName = React.ComponentProps<typeof Icon>['name'];
|
||||
|
||||
interface InfoBannerProps {
|
||||
/** Optional — a one-line banner reads better without one. */
|
||||
@@ -102,7 +102,7 @@ const InfoBannerComponent = ({
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={icon ?? DEFAULT_ICON[tone]}
|
||||
size={18}
|
||||
color={palette.fg}
|
||||
@@ -130,7 +130,7 @@ const InfoBannerComponent = ({
|
||||
style={styles.action}
|
||||
>
|
||||
<Text style={[theme.text.button, { color: palette.fg }]}>{actionLabel}</Text>
|
||||
<Ionicons name="chevron-forward" size={15} color={palette.fg} />
|
||||
<Icon name="chevron-forward" size={15} color={palette.fg} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
type TextInputProps,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
@@ -154,7 +154,7 @@ export const Input = ({
|
||||
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} />
|
||||
<Icon name="alert-circle" size={14} color={theme.colors.danger} />
|
||||
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
@@ -5,16 +5,7 @@ import {
|
||||
StyleSheet,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type TextInput,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
|
||||
|
||||
interface KeyboardAwareScrollViewProps {
|
||||
@@ -46,8 +37,14 @@ export const KeyboardAwareScrollView = ({
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const { keyboardHeight, isVisible } = useKeyboardHandler();
|
||||
|
||||
// Calculate the dynamic padding: keyboard height + extra buffer
|
||||
const bottomPad = isVisible ? keyboardHeight + extraPadding : 0;
|
||||
/**
|
||||
* iOS only. Android's window already ends at the top of the keyboard (see
|
||||
* `MainActivity.restoreAdjustResize`), so adding the keyboard's height there
|
||||
* as well left a keyboard-sized empty gap to scroll through.
|
||||
*/
|
||||
const bottomPad = isVisible && Platform.OS === 'ios' ? keyboardHeight + extraPadding : 0;
|
||||
const basePadding =
|
||||
(StyleSheet.flatten(contentContainerStyle)?.paddingBottom as number | undefined) ?? 40;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -55,7 +52,10 @@ export const KeyboardAwareScrollView = ({
|
||||
style={[styles.flex, style]}
|
||||
contentContainerStyle={[
|
||||
contentContainerStyle,
|
||||
{ paddingBottom: (contentContainerStyle as any)?.paddingBottom || 40 + bottomPad },
|
||||
// The keyboard padding is ADDED to the caller's own. It used to be
|
||||
// `callerPadding || 40 + keyboard`, so any screen that set its own
|
||||
// bottom padding silently lost the keyboard padding altogether.
|
||||
{ paddingBottom: basePadding + bottomPad },
|
||||
]}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
@@ -91,9 +91,10 @@ export const KeyboardSafeView = ({
|
||||
showsVerticalScrollIndicator = false,
|
||||
bounces = true,
|
||||
}: KeyboardSafeViewProps) => {
|
||||
// iOS: 'padding' works best, adds padding at the bottom
|
||||
// Android: 'height' or undefined works best (Android handles it natively in most cases)
|
||||
const behavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
|
||||
// iOS: 'padding'. Android: nothing — the window itself ends at the top of
|
||||
// the keyboard (adjustResize, restored on 15+ by MainActivity), and a
|
||||
// 'height' KeyboardAvoidingView on top of that shrank the content twice.
|
||||
const behavior = Platform.OS === 'ios' ? ('padding' as const) : undefined;
|
||||
|
||||
// Base offset: accounts for navigation header height on iOS
|
||||
// Android's windowSoftInputMode handles most of the offset natively
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -37,12 +35,24 @@ export const AppModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.flex}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
|
||||
>
|
||||
<Modal
|
||||
transparent
|
||||
statusBarTranslucent
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{/*
|
||||
Self-correcting on both platforms. The keyboard is measured in screen
|
||||
coordinates and this view's frame is relative to the modal's window,
|
||||
which `statusBarTranslucent` starts at the very top of the screen — so
|
||||
the two line up and no offset is needed. If the window was resized for
|
||||
the keyboard (Android's adjustResize), the frame already ends at the
|
||||
keyboard and the padding comes out as zero; if it was not (iOS, or an
|
||||
edge-to-edge Android 15 window), it pads by exactly the overlap.
|
||||
`height` on Android shrank a window that had often already shrunk.
|
||||
*/}
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.flex}>
|
||||
<TouchableWithoutFeedback onPress={handleOverlayPress}>
|
||||
<View style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}>
|
||||
<TouchableWithoutFeedback onPress={() => {}}>
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import { AppHeader } from '../AppHeader';
|
||||
import { TAB_BAR_HEIGHT } from '../../utils/layoutConstants';
|
||||
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
|
||||
|
||||
interface ScreenShellProps {
|
||||
title?: string;
|
||||
@@ -59,27 +60,47 @@ export const ScreenShell = ({
|
||||
fixedFooter,
|
||||
}: ScreenShellProps) => {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
/**
|
||||
* The tab bar hides while the keyboard is up (`tabBarHideOnKeyboard`), so
|
||||
* the room reserved for it has to go too — otherwise a 90pt band of nothing
|
||||
* sits between the last field, or the Pay button, and the keyboard, and on a
|
||||
* short phone that band is exactly where the field you are typing in was.
|
||||
*/
|
||||
const { isVisible: keyboardUp } = useKeyboardHandler();
|
||||
const reserveTabBar = withTabBar && !keyboardUp;
|
||||
|
||||
/**
|
||||
* Android is handled by the manifest, not by React.
|
||||
* Android is handled by the window, not by React.
|
||||
*
|
||||
* `windowSoftInputMode="adjustResize"` already shrinks the window when the
|
||||
* keyboard opens. Running a KeyboardAvoidingView on top of that shrinks the
|
||||
* keyboard opens — natively on Android 14 and earlier, and on 15+ through
|
||||
* the IME-inset padding in `MainActivity.restoreAdjustResize`, since an
|
||||
* edge-to-edge window is no longer resized by the system. Either way the
|
||||
* React root ends at the top of the keyboard.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* KeyboardAvoidingView compares the keyboard's SCREEN position with its own
|
||||
* layout frame, which is relative to its parent — here the frame view, which
|
||||
* starts below the top safe area. The offset is therefore exactly that
|
||||
* inset. It was a fixed 90, which over-subtracted on every notched iPhone
|
||||
* and left a fixed footer's lower edge — the Pay or Continue button —
|
||||
* tucked under the keyboard.
|
||||
*/
|
||||
const totalOffset = insets.top + keyboardExtraOffset;
|
||||
|
||||
// Bottom spacing: if inside tab navigator, add TAB_BAR_HEIGHT + breathing room
|
||||
// If there's a fixedFooter, we don't necessarily need tab bar padding on the scroll view,
|
||||
// but let's leave it for safety or adjust based on layout.
|
||||
// A fixed footer already clears the tab bar (see below), so the scroll above
|
||||
// it only needs breathing room, not the bar's full height again.
|
||||
const tabBarPadding = withTabBar && !fixedFooter ? TAB_BAR_HEIGHT + 16 : 20;
|
||||
const tabBarPadding = reserveTabBar && !fixedFooter ? TAB_BAR_HEIGHT + 16 : 20;
|
||||
|
||||
const scrollContent = (
|
||||
<ScrollView
|
||||
@@ -114,7 +135,7 @@ export const ScreenShell = ({
|
||||
style={[
|
||||
styles.content,
|
||||
styles.staticContent,
|
||||
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
|
||||
{ paddingBottom: reserveTabBar ? TAB_BAR_HEIGHT : 0 },
|
||||
contentContainerStyle,
|
||||
]}
|
||||
>
|
||||
@@ -156,7 +177,7 @@ export const ScreenShell = ({
|
||||
Continue buttons were rendering behind the bar. Lifting it by the
|
||||
bar's height keeps it above. */}
|
||||
{fixedFooter ? (
|
||||
<View style={withTabBar ? { paddingBottom: TAB_BAR_HEIGHT } : undefined}>
|
||||
<View style={reserveTabBar ? { paddingBottom: TAB_BAR_HEIGHT } : undefined}>
|
||||
{fixedFooter}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Icon } from '../Icon';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
@@ -103,7 +103,7 @@ const StepperComponent = ({ steps, current, onBrand = false, style }: StepperPro
|
||||
]}
|
||||
>
|
||||
{done ? (
|
||||
<Ionicons name="checkmark" size={13} color={markerInk} />
|
||||
<Icon name="checkmark" size={13} color={markerInk} />
|
||||
) : (
|
||||
<Text style={[theme.text.eyebrow, styles.markerText, { color: markerInk }]}>
|
||||
{index + 1}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import type { TransactionRecord } from '../../types/app';
|
||||
import { useTheme, textStyles } from '../../theme';
|
||||
@@ -46,7 +46,7 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
|
||||
color: theme.colors.danger,
|
||||
surface: theme.colors.dangerSurface,
|
||||
border: theme.colors.dangerBorder,
|
||||
label: 'HIGH RISK',
|
||||
label: 'High risk',
|
||||
icon: 'alert-circle' as const,
|
||||
},
|
||||
}[riskLevel];
|
||||
@@ -65,7 +65,7 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isCredit ? 'chevron-down' : 'chevron-up'}
|
||||
size={18}
|
||||
color={isCredit ? theme.colors.success : theme.colors.textSecondary}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface TypewriterTextProps {
|
||||
/** One line, typed once. */
|
||||
text?: string;
|
||||
/**
|
||||
* Several lines, typed in turn: each is typed, held, deleted, and the next
|
||||
* one typed, round and round. Takes precedence over `text`.
|
||||
*/
|
||||
texts?: readonly string[];
|
||||
/**
|
||||
* Types only while true; switching it back on restarts from the first
|
||||
* character. A paging carousel passes "is this the slide on screen", so each
|
||||
* slide types out as it arrives rather than all of them at mount.
|
||||
*/
|
||||
active?: boolean;
|
||||
/** Milliseconds per character typed. */
|
||||
speed?: number;
|
||||
/** Milliseconds per character deleted — quicker than typing, as a person is. */
|
||||
deleteSpeed?: number;
|
||||
/** How long a finished line stays up before it is deleted (`texts` only). */
|
||||
holdMs?: number;
|
||||
/** Pause before the first character, so the slide has settled. */
|
||||
startDelay?: number;
|
||||
style?: StyleProp<TextStyle>;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
/** Caret colour. Defaults to the brand blue. */
|
||||
caretColor?: string;
|
||||
/** Keep the caret blinking after a single line has finished. */
|
||||
keepCaret?: boolean;
|
||||
/** Fired once a single `text` has finished typing. Not fired for `texts`. */
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'waiting' | 'typing' | 'holding' | 'deleting' | 'done';
|
||||
|
||||
interface Step {
|
||||
/** Which line, in `texts`. */
|
||||
line: number;
|
||||
/** Characters of it on screen. */
|
||||
count: number;
|
||||
phase: Phase;
|
||||
}
|
||||
|
||||
const START: Step = { line: 0, count: 0, phase: 'waiting' };
|
||||
|
||||
/** The gap between deleting one line and starting the next. */
|
||||
const BETWEEN_LINES_MS = 350;
|
||||
|
||||
/**
|
||||
* Text that types itself out, with a blinking caret — one line once, or
|
||||
* several in rotation.
|
||||
*
|
||||
* Three things it is careful about:
|
||||
* - LAYOUT. The longest line is laid out invisibly underneath and the typed
|
||||
* part drawn over it, so the block holds its final size from the first
|
||||
* frame. Nothing around it jumps as characters land or lines change.
|
||||
* - SCREEN READERS. The block announces every line once; the half-typed
|
||||
* frames are hidden from accessibility, so nobody hears it spelled out.
|
||||
* - REDUCED MOTION. With the system setting on, the first line simply
|
||||
* appears and stays.
|
||||
*
|
||||
* Driven one step at a time by a single timeout, rather than by intervals:
|
||||
* every transition — next character, hold, delete, next line — is the same
|
||||
* kind of event, and there is never more than one timer to clean up.
|
||||
*/
|
||||
const TypewriterTextComponent = ({
|
||||
text,
|
||||
texts,
|
||||
active = true,
|
||||
speed = 45,
|
||||
deleteSpeed = 22,
|
||||
holdMs = 1800,
|
||||
startDelay = 250,
|
||||
style,
|
||||
containerStyle,
|
||||
caretColor,
|
||||
keepCaret = false,
|
||||
onDone,
|
||||
}: TypewriterTextProps) => {
|
||||
const theme = useTheme();
|
||||
const [step, setStep] = useState<Step>(START);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [caretOn, setCaretOn] = useState(true);
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
const lines = useMemo(
|
||||
() => (texts && texts.length > 0 ? [...texts] : [text ?? '']),
|
||||
// Joined so a new array with the same lines is not a restart.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[texts?.join('\u0000'), text],
|
||||
);
|
||||
const rotating = lines.length > 1;
|
||||
const longest = useMemo(
|
||||
() => lines.reduce((a, b) => (b.length > a.length ? b : a), ''),
|
||||
[lines],
|
||||
);
|
||||
const current = lines[step.line % lines.length] ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
AccessibilityInfo.isReduceMotionEnabled()
|
||||
.then((enabled) => mounted && setReduceMotion(enabled))
|
||||
.catch(() => {});
|
||||
|
||||
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
sub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Back to the start whenever it is (re)activated or the lines change.
|
||||
useEffect(() => {
|
||||
setStep(START);
|
||||
}, [active, lines]);
|
||||
|
||||
// One step at a time.
|
||||
useEffect(() => {
|
||||
if (!active || step.phase === 'done') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reduceMotion) {
|
||||
setStep({ line: 0, count: lines[0]?.length ?? 0, phase: 'done' });
|
||||
return;
|
||||
}
|
||||
|
||||
const advance = (): { next: Step; delay: number } => {
|
||||
switch (step.phase) {
|
||||
case 'waiting':
|
||||
return { next: { ...step, phase: 'typing' }, delay: startDelay };
|
||||
|
||||
case 'typing':
|
||||
if (step.count < current.length) {
|
||||
return { next: { ...step, count: step.count + 1 }, delay: speed };
|
||||
}
|
||||
return rotating
|
||||
? { next: { ...step, phase: 'holding' }, delay: 0 }
|
||||
: { next: { ...step, phase: 'done' }, delay: 0 };
|
||||
|
||||
case 'holding':
|
||||
return { next: { ...step, phase: 'deleting' }, delay: holdMs };
|
||||
|
||||
case 'deleting':
|
||||
if (step.count > 0) {
|
||||
return { next: { ...step, count: step.count - 1 }, delay: deleteSpeed };
|
||||
}
|
||||
return {
|
||||
next: { line: (step.line + 1) % lines.length, count: 0, phase: 'typing' },
|
||||
delay: BETWEEN_LINES_MS,
|
||||
};
|
||||
|
||||
default:
|
||||
return { next: step, delay: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const { next, delay } = advance();
|
||||
const timer = setTimeout(() => setStep(next), delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, current.length, deleteSpeed, holdMs, lines, reduceMotion, rotating, speed, startDelay, step]);
|
||||
|
||||
// `onDone` once, when a single line lands.
|
||||
useEffect(() => {
|
||||
if (step.phase === 'done' && !rotating) {
|
||||
onDoneRef.current?.();
|
||||
}
|
||||
}, [rotating, step.phase]);
|
||||
|
||||
const moving = step.phase === 'typing' || step.phase === 'deleting';
|
||||
const showCaret =
|
||||
!reduceMotion && active && (step.phase !== 'done' || keepCaret) && step.phase !== 'waiting';
|
||||
|
||||
/**
|
||||
* Solid while characters are moving, blinking while it waits. Blinked by
|
||||
* colour, not opacity: the caret is a span nested inside the text, and
|
||||
* Android ignores opacity on a nested span. Transparent keeps its width, so
|
||||
* nothing shifts as it blinks.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setCaretOn(true);
|
||||
|
||||
if (!showCaret || moving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blink = setInterval(() => setCaretOn((on) => !on), 500);
|
||||
|
||||
return () => clearInterval(blink);
|
||||
}, [moving, showCaret]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
accessible
|
||||
accessibilityRole="text"
|
||||
accessibilityLabel={lines.join('. ')}
|
||||
>
|
||||
{/* Holds the size of the longest line. Invisible, and not read out. */}
|
||||
<Text style={[style, styles.ghost]} importantForAccessibility="no-hide-descendants">
|
||||
{longest}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[style, styles.overlay]}
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
accessibilityElementsHidden
|
||||
>
|
||||
{current.slice(0, step.count)}
|
||||
{showCaret ? (
|
||||
<Text style={{ color: caretOn ? caretColor ?? theme.colors.primary : 'transparent' }}>
|
||||
|
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
ghost: {
|
||||
opacity: 0,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
});
|
||||
|
||||
export const TypewriterText = memo(TypewriterTextComponent);
|
||||
@@ -19,3 +19,4 @@ export * from './Stepper';
|
||||
export * from './SegmentedControl';
|
||||
export * from './ToastHost';
|
||||
export * from './TransactionItem';
|
||||
export * from './TypewriterText';
|
||||
|
||||
+17
-7
@@ -7,12 +7,15 @@
|
||||
* 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.
|
||||
* This is a name only the office network's DNS knows (it resolves to
|
||||
* 192.168.6.1), so the handset has to be on the same network (office Wi-Fi or
|
||||
* the VPN). On mobile data or a guest network it does not resolve and the app
|
||||
* looks like the backend is down.
|
||||
*
|
||||
* Positive Pay follows the 12 Oct 2026 guide, which names accounts by
|
||||
* `accountNumber`; the 13 Oct revision keeps that contract and names this host
|
||||
* as the deployment base URL. A backend still on the earlier build (account
|
||||
* ids) refuses those calls — check `/v3/api-docs` here if Positive Pay fails.
|
||||
*
|
||||
* 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
|
||||
@@ -38,7 +41,14 @@ export const env = {
|
||||
* 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,
|
||||
useMockServer: false,
|
||||
/**
|
||||
* Debug builds only: answers sign-in, registration, accounts, payees and
|
||||
* transfer history from `services/api/devMockAdapter.ts` instead of the bank,
|
||||
* accepting any mobile number, OTP and MPIN. For running the app off the
|
||||
* office network. Set false to talk to the real backend again.
|
||||
*/
|
||||
devMockBackend: false,
|
||||
/** Server-enforced idle window; any authenticated call refreshes it. */
|
||||
sessionTimeoutMs: 300_000,
|
||||
/** How long before the idle cut-off the user is warned. */
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum FeatureKey {
|
||||
AUTH_LOGIN_BIOMETRIC = 'auth.login.biometric',
|
||||
AUTH_LOGIN_FORGOT_PASSWORD = 'auth.login.forgotPassword',
|
||||
AUTH_REGISTER = 'auth.register',
|
||||
AUTH_ONBOARDING = 'auth.onboarding',
|
||||
AUTH_LOGIN_CREATE_ACCOUNT = 'auth.login.createAccount',
|
||||
AUTH_LOGOUT = 'auth.logout',
|
||||
|
||||
@@ -136,6 +137,9 @@ export enum FeatureKey {
|
||||
SERVICES_CHEQUE_STOP_PAYMENT = 'services.cheque.stopPayment',
|
||||
SERVICES_CHEQUE_TRACKING = 'services.cheque.tracking',
|
||||
SERVICES_CHEQUE_POSITIVE_PAY = 'services.cheque.positivePay',
|
||||
SERVICES_CHEQUE_POSITIVE_PAY_HISTORY = 'services.cheque.positivePay.history',
|
||||
/** One saved Positive Pay cheque. Reached from the history and from Stop Payment alike. */
|
||||
SERVICES_CHEQUE_DETAIL = 'services.cheque.detail',
|
||||
|
||||
SERVICES_CERTIFICATES = 'services.certificates',
|
||||
SERVICES_CERTIFICATES_HUB = 'services.certificates.hub',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const QUERY_TAGS = [
|
||||
'Deposits',
|
||||
'Services',
|
||||
'ChequeBooks',
|
||||
'PositivePay',
|
||||
'Loans',
|
||||
'LoanActive',
|
||||
'LoanApplications',
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { FeatureKey } from '../constants/featureKeys';
|
||||
import type { FeatureDefinition } from './types';
|
||||
|
||||
/**
|
||||
* Snapshot of DEFAULT_FEATURE_CONFIG as it stood on 14 Oct 2026, the release
|
||||
* stage before every feature was switched on. Not imported anywhere — it is
|
||||
* the record of which features that stage shipped off, and why.
|
||||
*
|
||||
* To return to this stage, make DEFAULT_FEATURE_CONFIG in `featureConfig.ts`
|
||||
* equal to this map (or copy its `enabled: false` entries back).
|
||||
*/
|
||||
export const STAGE_2026_10_14_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: false,
|
||||
description: 'Agent login tab',
|
||||
reason:
|
||||
'Off with the agent app itself. This key is not under `agent`, so it does not ' +
|
||||
'inherit that kill — left on, the tab would offer a role whose app no longer ' +
|
||||
'exists, and RootNavigator would drop the agent into the customer app instead.',
|
||||
},
|
||||
[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_ONBOARDING]: {
|
||||
enabled: true,
|
||||
description: 'First-launch introduction slides, shown once before registration',
|
||||
},
|
||||
[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_CHEQUE_POSITIVE_PAY_HISTORY]: {
|
||||
enabled: true,
|
||||
description: 'The saved Positive Pay cheques and their status',
|
||||
},
|
||||
[FeatureKey.SERVICES_CHEQUE_DETAIL]: {
|
||||
enabled: true,
|
||||
description: 'One saved Positive Pay cheque, with stop payment when it is eligible',
|
||||
},
|
||||
|
||||
[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 },
|
||||
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
|
||||
// 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: false,
|
||||
reason: 'No backend endpoint: GET /profile is read-only and editing is outside its scope',
|
||||
},
|
||||
[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: false, reason: 'Removed from the profile screen' },
|
||||
[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_RESET_MPIN]: { 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: false,
|
||||
description: 'Field-agent application',
|
||||
reason:
|
||||
'Parked for now. Disabling this root retires the whole agent app — the 24 keys ' +
|
||||
'below inherit it, so they are left as they are and the module comes back in ' +
|
||||
'one edit. Pair with `auth.login.agentRole`, which sits outside this subtree.',
|
||||
},
|
||||
|
||||
[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 },
|
||||
};
|
||||
@@ -16,6 +16,10 @@ import type { FeatureDefinition, FeatureOverrides } from './types';
|
||||
* 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.
|
||||
*
|
||||
* Every feature is on as of 14 Oct 2026. The previous stage, with the features
|
||||
* it shipped off and the reason for each, is kept in
|
||||
* `featureConfig.stage-2026-10-14.ts`.
|
||||
*/
|
||||
export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
// ---------------------------------------------------------------- Auth ----
|
||||
@@ -23,12 +27,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[FeatureKey.AUTH_LOGIN]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
|
||||
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
description: 'Agent login tab',
|
||||
reason:
|
||||
'Off with the agent app itself. This key is not under `agent`, so it does not ' +
|
||||
'inherit that kill — left on, the tab would offer a role whose app no longer ' +
|
||||
'exists, and RootNavigator would drop the agent into the customer app instead.',
|
||||
},
|
||||
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
|
||||
@@ -36,6 +36,10 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
enabled: true,
|
||||
description: 'Device registration: lookup, OTP, MPIN setup',
|
||||
},
|
||||
[FeatureKey.AUTH_ONBOARDING]: {
|
||||
enabled: true,
|
||||
description: 'First-launch introduction slides, shown once before registration',
|
||||
},
|
||||
[FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGOUT]: { enabled: true },
|
||||
|
||||
@@ -58,8 +62,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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_LOANS]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true },
|
||||
|
||||
[FeatureKey.HOME_SERVICES]: { enabled: true },
|
||||
@@ -69,9 +73,9 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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_INSURANCE]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' },
|
||||
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: true },
|
||||
|
||||
[FeatureKey.HOME_INSIGHTS]: { enabled: true },
|
||||
[FeatureKey.HOME_PROMO_BANNER]: { enabled: true },
|
||||
@@ -94,8 +98,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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_NEFT]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true },
|
||||
@@ -109,24 +113,24 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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' },
|
||||
[FeatureKey.PAYMENTS_UPI]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: true },
|
||||
|
||||
// --------------------------------------------------------------- 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' },
|
||||
[FeatureKey.CARDS]: { enabled: true, description: 'Cards tab' },
|
||||
[FeatureKey.CARDS_OVERVIEW]: { enabled: true },
|
||||
[FeatureKey.CARDS_DETAILS]: { enabled: true },
|
||||
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: true },
|
||||
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: true },
|
||||
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: true },
|
||||
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: true },
|
||||
|
||||
// ------------------------------------------------------------ Services ----
|
||||
[FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' },
|
||||
@@ -135,11 +139,10 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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' },
|
||||
// Opening a deposit: the route and every control that reaches it are gated
|
||||
// on these two keys.
|
||||
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: true },
|
||||
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: true },
|
||||
[FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_CHEQUE]: { enabled: true },
|
||||
@@ -148,24 +151,32 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY_HISTORY]: {
|
||||
enabled: true,
|
||||
description: 'The saved Positive Pay cheques and their status',
|
||||
},
|
||||
[FeatureKey.SERVICES_CHEQUE_DETAIL]: {
|
||||
enabled: true,
|
||||
description: 'One saved Positive Pay cheque, with stop payment when it is eligible',
|
||||
},
|
||||
|
||||
[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_CERTIFICATES]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: true },
|
||||
|
||||
[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_REQUESTS]: { enabled: true },
|
||||
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: true },
|
||||
|
||||
[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_STANDING_INSTRUCTIONS]: { enabled: true },
|
||||
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: true },
|
||||
[FeatureKey.SERVICES_NOMINATION]: { enabled: true },
|
||||
[FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_AWARENESS]: { enabled: true },
|
||||
@@ -185,10 +196,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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_BALANCE_ENQUIRY]: { enabled: true },
|
||||
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
|
||||
@@ -198,31 +206,31 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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' },
|
||||
[FeatureKey.LOANS]: { enabled: true, description: 'Loans module' },
|
||||
[FeatureKey.LOANS_DASHBOARD]: { enabled: true },
|
||||
[FeatureKey.LOANS_TYPES]: { enabled: true },
|
||||
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: true },
|
||||
[FeatureKey.LOANS_CALCULATOR]: { enabled: true },
|
||||
[FeatureKey.LOANS_APPLY]: { enabled: true },
|
||||
[FeatureKey.LOANS_TRACKING]: { enabled: true },
|
||||
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: true },
|
||||
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: true },
|
||||
[FeatureKey.LOANS_FORECLOSURE]: { enabled: true },
|
||||
[FeatureKey.LOANS_HISTORY]: { enabled: true },
|
||||
[FeatureKey.LOANS_DOCUMENTS]: { enabled: true },
|
||||
|
||||
// ----------------------------------------------------------- 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' },
|
||||
[FeatureKey.INSURANCE]: { enabled: true, description: 'Insurance module' },
|
||||
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_BUY]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_POLICIES]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_RENEWALS]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_CLAIMS]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_COMPARISON]: { enabled: true },
|
||||
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: true },
|
||||
|
||||
// ------------------------------------------------------------- Profile ----
|
||||
[FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' },
|
||||
@@ -252,21 +260,16 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
[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,
|
||||
// On with everything else. Shipped on by default, this switchboard is
|
||||
// reachable by every customer — turn it off before a customer release.
|
||||
enabled: true,
|
||||
description: 'Operational feature switchboard (admin)',
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------- Agent ----
|
||||
[FeatureKey.AGENT]: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
description: 'Field-agent application',
|
||||
reason:
|
||||
'Parked for now. Disabling this root retires the whole agent app — the 24 keys ' +
|
||||
'below inherit it, so they are left as they are and the module comes back in ' +
|
||||
'one edit. Pair with `auth.login.agentRole`, which sits outside this subtree.',
|
||||
},
|
||||
|
||||
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
interface AgentEmptyStateProps {
|
||||
@@ -22,8 +22,8 @@ export const AgentEmptyState = ({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name={icon as any} size={48} color={theme.colors.primary} />
|
||||
<View style={[styles.iconContainer, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
|
||||
<Icon name={icon as any} size={48} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textMuted }]}>{description}</Text>
|
||||
|
||||
@@ -60,7 +60,7 @@ export const AgentProgressRing = ({
|
||||
<Svg width={size} height={size}>
|
||||
{/* Background Circle */}
|
||||
<Circle
|
||||
stroke={`${ringColor}20`}
|
||||
stroke={theme.tint(ringColor, 0.13)}
|
||||
fill="none"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
@@ -38,12 +38,12 @@ export const AgentStatCard = ({
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${baseColor}20` }]}>
|
||||
<Ionicons name={icon as any} size={18} color={baseColor} />
|
||||
<View style={[styles.iconFrame, { backgroundColor: theme.tint(baseColor, 0.13) }]}>
|
||||
<Icon name={icon as any} size={18} color={baseColor} />
|
||||
</View>
|
||||
{trend !== undefined && (
|
||||
<View style={[styles.trendBadge, { backgroundColor: isPositive ? `${theme.colors.success}20` : isNegative ? `${theme.colors.danger}20` : `${theme.colors.textMuted}20` }]}>
|
||||
<Ionicons
|
||||
<View style={[styles.trendBadge, { backgroundColor: isPositive ? theme.tint(theme.colors.success, 0.13) : isNegative ? theme.tint(theme.colors.danger, 0.13) : theme.tint(theme.colors.textMuted, 0.13) }]}>
|
||||
<Icon
|
||||
name={isPositive ? 'trending-up' : isNegative ? 'trending-down' : 'remove'}
|
||||
size={12}
|
||||
color={isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted}
|
||||
@@ -66,7 +66,7 @@ export const AgentStatCard = ({
|
||||
{/* Tap indicator arrow */}
|
||||
{onPress && (
|
||||
<View style={styles.tapIndicator}>
|
||||
<Ionicons name="chevron-forward" size={14} color={gradient ? 'rgba(255,255,255,0.4)' : theme.colors.textMuted} />
|
||||
<Icon name="chevron-forward" size={14} color={gradient ? 'rgba(255,255,255,0.4)' : theme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CollectionEntry } from '../types/agentTypes';
|
||||
import { formatAgentCurrency, formatTime } from '../utils/agentFormatters';
|
||||
@@ -36,8 +36,8 @@ export const CollectionEntryCard = ({ entry, onPress }: CollectionEntryCardProps
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.leftContent}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: `${statusColor}15` }]}>
|
||||
<Ionicons
|
||||
<View style={[styles.iconContainer, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
|
||||
<Icon
|
||||
name={isCollected ? 'checkmark-circle' : isPartial ? 'pie-chart' : 'time'}
|
||||
size={20}
|
||||
color={statusColor}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CommissionSummary } from '../types/agentTypes';
|
||||
import { formatAgentCurrency } from '../utils/agentFormatters';
|
||||
@@ -53,8 +53,8 @@ export const CommissionSummaryCard = ({ summary }: CommissionSummaryCardProps) =
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.rateBadge, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name="information-circle" size={14} color={theme.colors.primary} />
|
||||
<View style={[styles.rateBadge, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name="information-circle" size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.rateText, { color: theme.colors.primary }]}>
|
||||
Calculated at {summary.commissionRate}% commission rate
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { AgentCustomer } from '../types/agentTypes';
|
||||
import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters';
|
||||
@@ -46,7 +46,7 @@ export const CustomerCard = ({
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.profileInfo}>
|
||||
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<View style={[styles.avatar, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>
|
||||
{customer.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
@@ -54,7 +54,7 @@ export const CustomerCard = ({
|
||||
<View>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
|
||||
<View style={styles.phoneRow}>
|
||||
<Ionicons name="call-outline" size={12} color={theme.colors.textMuted} />
|
||||
<Icon name="call-outline" size={12} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.phone, { color: theme.colors.textMuted }]}>
|
||||
{maskPhone(customer.phone)}
|
||||
</Text>
|
||||
@@ -62,7 +62,7 @@ export const CustomerCard = ({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{isPaid ? 'Paid' : isOverdue ? 'Overdue' : 'Pending'}
|
||||
@@ -91,10 +91,10 @@ export const CustomerCard = ({
|
||||
|
||||
{onAction && (
|
||||
<Pressable
|
||||
style={[styles.actionBtn, { backgroundColor: `${actionColor || theme.colors.primary}15` }]}
|
||||
style={[styles.actionBtn, { backgroundColor: theme.tint(actionColor || theme.colors.primary, 0.08) }]}
|
||||
onPress={onAction}
|
||||
>
|
||||
<Ionicons name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
|
||||
<Icon name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -162,9 +162,8 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 3,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Alert, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
|
||||
|
||||
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
|
||||
@@ -31,7 +31,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI
|
||||
}
|
||||
]}>
|
||||
{i < currentStepIndex ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
<Icon name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : (
|
||||
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontFamily: fontFamily.bold }}>{i + 1}</Text>
|
||||
)}
|
||||
@@ -94,9 +94,9 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
|
||||
{/* Local header */}
|
||||
<View style={styles.localHeader}>
|
||||
<Pressable onPress={() => navigation.goBack()} hitSlop={12} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
|
||||
<Icon name="chevron-back" size={24} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>NEW ACCOUNT</Text>
|
||||
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>New account</Text>
|
||||
</View>
|
||||
|
||||
<StepIndicator steps={steps} currentStepIndex={currentStepIndex} />
|
||||
@@ -121,8 +121,8 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
|
||||
]}
|
||||
onPress={() => setAccountType(type.id)}
|
||||
>
|
||||
<View style={[styles.typeIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name={type.icon as any} size={28} color={theme.colors.primary} />
|
||||
<View style={[styles.typeIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name={type.icon as any} size={28} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.typeLabel, { color: theme.colors.textPrimary }]}>{type.label}</Text>
|
||||
@@ -245,8 +245,8 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.warningBox, { backgroundColor: `${theme.colors.info}10`, borderColor: `${theme.colors.info}30` }]}>
|
||||
<Ionicons name="cash-outline" size={20} color={theme.colors.info} />
|
||||
<View style={[styles.warningBox, { backgroundColor: theme.tint(theme.colors.info, 0.06), borderColor: theme.tint(theme.colors.info, 0.19) }]}>
|
||||
<Icon name="cash-outline" size={20} color={theme.colors.info} />
|
||||
<Text style={[styles.warningText, { color: theme.colors.info }]}>
|
||||
Please collect the initial deposit amount from the customer in cash before proceeding.
|
||||
</Text>
|
||||
@@ -400,9 +400,8 @@ const styles = StyleSheet.create({
|
||||
marginTop: 8,
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.semibold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
width: 54,
|
||||
textAlign: 'center',
|
||||
@@ -478,8 +477,7 @@ const styles = StyleSheet.create({
|
||||
sectionDividerText: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
paddingHorizontal: 12,
|
||||
marginTop: -8,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
|
||||
@@ -21,8 +21,8 @@ const FormSection = ({ title, icon, children }: { title: string, icon: string, c
|
||||
return (
|
||||
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<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 style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name={icon as any} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</View>
|
||||
@@ -103,7 +103,7 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
|
||||
variant="secondary"
|
||||
onPress={handleFetchClient}
|
||||
loading={isLookingUp}
|
||||
leftAccessory={<Ionicons name="search-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="search-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ScrollView, RefreshControl, Pressable } from 'r
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
@@ -99,8 +99,8 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
|
||||
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>{getGreeting()},</Text>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
|
||||
</View>
|
||||
<View style={[styles.avatarFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name="person" size={20} color={theme.colors.primary} />
|
||||
<View style={[styles.avatarFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name="person" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Text, Pressable, ScrollView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
@@ -47,7 +47,7 @@ export default function AgentLoansScreen({ route, navigation }: Props) {
|
||||
]}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={tab.icon as any}
|
||||
size={16}
|
||||
color={isActive ? theme.colors.white : theme.colors.textMuted}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-nati
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
@@ -46,12 +46,12 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.settingsRowLeft}>
|
||||
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? `${theme.colors.danger}15` : `${theme.colors.primary}15` }]}>
|
||||
<Ionicons name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
|
||||
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? theme.tint(theme.colors.danger, 0.08) : theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.settingsLabel, { color: destructive ? theme.colors.danger : theme.colors.textPrimary }]}>{label}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
<Icon name="chevron-forward" size={20} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -60,12 +60,12 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
|
||||
<Animated.View entering={FadeInUp} style={[styles.profileCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{profile?.firstName?.charAt(0) || 'A'}</Text>
|
||||
</View>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName} {profile?.lastName}</Text>
|
||||
<Text style={[styles.role, { color: theme.colors.textMuted }]}>Field Agent • {profile?.email}</Text>
|
||||
<View style={[styles.badge, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<View style={[styles.badge, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: theme.colors.success }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.success }]}>Active Agent</Text>
|
||||
</View>
|
||||
@@ -184,8 +184,7 @@ const styles = StyleSheet.create({
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontFamily: fontFamily.semibold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
marginBottom: 8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Share } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
@@ -49,7 +49,7 @@ export default function AgentRouteScreen(_props: Props) {
|
||||
<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} />
|
||||
<Icon 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) => (
|
||||
@@ -81,8 +81,8 @@ export default function AgentRouteScreen(_props: Props) {
|
||||
<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
|
||||
<View style={[styles.stopDot, { backgroundColor: theme.tint(statusColor(stop.status), 0.13) }]}>
|
||||
<Icon
|
||||
name={stop.status === 'done' ? 'checkmark' : 'location'}
|
||||
size={14}
|
||||
color={statusColor(stop.status)}
|
||||
@@ -104,7 +104,7 @@ export default function AgentRouteScreen(_props: Props) {
|
||||
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} />}
|
||||
leftAccessory={<Icon name="navigate-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<Button
|
||||
title="Share Route"
|
||||
@@ -112,7 +112,7 @@ export default function AgentRouteScreen(_props: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.actionBtn}
|
||||
onPress={handleShare}
|
||||
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ height: 32 }} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
@@ -51,8 +51,8 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
|
||||
<Animated.View entering={SlideInUp.springify()} style={[styles.receiptCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
|
||||
<View style={styles.successHeader}>
|
||||
<View style={[styles.successIcon, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="checkmark" size={32} color={theme.colors.success} />
|
||||
<View style={[styles.successIcon, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
|
||||
<Icon name="checkmark" size={32} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.successTitle, { color: theme.colors.success }]}>Payment Successful</Text>
|
||||
<Text style={[styles.successAmount, { color: theme.colors.textPrimary }]}>{formatFullCurrency(receipt.amount)}</Text>
|
||||
@@ -81,12 +81,12 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
|
||||
|
||||
<Animated.View entering={FadeIn.delay(300)} style={styles.actionsRow}>
|
||||
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="share-outline" size={20} color={theme.colors.primary} />
|
||||
<Icon name="share-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Share PDF</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="print-outline" size={20} color={theme.colors.primary} />
|
||||
<Icon name="print-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Print</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
|
||||
@@ -51,7 +51,7 @@ export default function CommissionDashboardScreen() {
|
||||
<Text style={styles.totalValue}>{formatAgentCurrency(summary.totalEarned)}</Text>
|
||||
</View>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name="star" size={24} color={theme.colors.primary} />
|
||||
<Icon name="star" size={24} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.totalDivider} />
|
||||
@@ -72,7 +72,7 @@ export default function CommissionDashboardScreen() {
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.historyCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.historyHeader}>
|
||||
<Text style={[styles.historyMonth, { color: theme.colors.textPrimary }]}>{item.month} {item.year}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.warning, 0.08) }]}>
|
||||
<Text style={[styles.statusText, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
|
||||
@@ -41,7 +41,7 @@ const StepIndicator = ({ step }: { step: number }) => {
|
||||
},
|
||||
]}>
|
||||
{isCompleted ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
<Icon name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : (
|
||||
<Text style={{
|
||||
color: isActive ? theme.colors.white : theme.colors.textMuted,
|
||||
@@ -210,7 +210,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
onPress={() => updateField('category', cat.id as any)}
|
||||
>
|
||||
<View style={[styles.catIcon, { backgroundColor: surface }]}>
|
||||
<Ionicons name={cat.icon as any} size={22} color={tone} />
|
||||
<Icon name={cat.icon as any} size={22} color={tone} />
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.catLabel,
|
||||
@@ -220,7 +220,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
<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
|
||||
<Icon
|
||||
name="checkmark"
|
||||
size={12}
|
||||
color={theme.colors.textOnAccentFill}
|
||||
@@ -269,7 +269,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
]}
|
||||
>
|
||||
<View style={[styles.bannerIcon, { backgroundColor: selectedTone.surface }]}>
|
||||
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
|
||||
<Icon name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.bannerTitle, { color: selectedTone.tone }]}>{selectedCategory.label}</Text>
|
||||
@@ -335,7 +335,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
{/* Header badge */}
|
||||
{selectedCategory && (
|
||||
<View style={[styles.reviewBadge, { backgroundColor: selectedTone.surface }]}>
|
||||
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
|
||||
<Icon name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
|
||||
<Text style={[styles.reviewBadgeText, { color: selectedTone.tone }]}>
|
||||
{selectedCategory.label}
|
||||
</Text>
|
||||
@@ -359,8 +359,8 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
|
||||
{/* Documents info */}
|
||||
<View style={[styles.docsCard, { backgroundColor: `${theme.colors.info}08`, borderColor: `${theme.colors.info}25` }]}>
|
||||
<Ionicons name="document-attach-outline" size={20} color={theme.colors.info} />
|
||||
<View style={[styles.docsCard, { backgroundColor: theme.tint(theme.colors.info, 0.03), borderColor: theme.tint(theme.colors.info, 0.15) }]}>
|
||||
<Icon name="document-attach-outline" size={20} color={theme.colors.info} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.docsTitle, { color: theme.colors.info }]}>Documents can be added later</Text>
|
||||
<Text style={[styles.docsText, { color: theme.colors.textMuted }]}>
|
||||
@@ -371,7 +371,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
|
||||
|
||||
{/* Consent */}
|
||||
<View style={[styles.consentBox, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.success} />
|
||||
<Icon name="shield-checkmark-outline" size={18} color={theme.colors.success} />
|
||||
<Text style={[styles.consentText, { color: theme.colors.textMuted }]}>
|
||||
By submitting, you confirm the above details are accurate and the customer has given consent for this application.
|
||||
</Text>
|
||||
@@ -414,9 +414,8 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
stepLine: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell, Button } from '../../../components';
|
||||
@@ -68,7 +68,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabelArea}>
|
||||
{icon && <Ionicons name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
|
||||
{icon && <Icon name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
|
||||
@@ -84,7 +84,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
{/* Profile Header */}
|
||||
<Animated.View entering={FadeInUp.delay(100)} style={styles.headerCard}>
|
||||
<View style={styles.profileTopRow}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<View style={[styles.avatarBig, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Text style={[styles.avatarBigText, { color: theme.colors.primary }]}>
|
||||
{customer.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
@@ -92,7 +92,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={[styles.headerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
|
||||
<Text style={[styles.headerPhone, { color: theme.colors.textSecondary }]}>{maskPhone(customer.phone)}</Text>
|
||||
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? `${theme.colors.success}15` : `${theme.colors.danger}15` }]}>
|
||||
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.danger, 0.08) }]}>
|
||||
<Text style={[styles.statusText, { color: customer.status === 'active' ? theme.colors.success : theme.colors.danger }]}>
|
||||
{customer.status.toUpperCase()}
|
||||
</Text>
|
||||
@@ -102,23 +102,23 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
|
||||
<View style={styles.actionButtonsRow}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => {/* Implement call */}}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.info}15` }]}>
|
||||
<Ionicons name="call" size={20} color={theme.colors.info} />
|
||||
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.info, 0.08) }]}>
|
||||
<Icon name="call" size={20} color={theme.colors.info} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.info }]}>Call</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.iconBtn} onPress={() => {/* Implement SMS */}}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="chatbubble" size={20} color={theme.colors.success} />
|
||||
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
|
||||
<Icon name="chatbubble" size={20} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</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 style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Icon name="create" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
|
||||
</Pressable>
|
||||
@@ -189,7 +189,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
|
||||
variant="ghost"
|
||||
loading={isDeleting}
|
||||
onPress={handleDeleteCustomer}
|
||||
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
|
||||
leftAccessory={<Icon name="trash-outline" size={18} color={theme.colors.danger} />}
|
||||
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, FlatList } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
@@ -46,8 +46,8 @@ export default function CustomerInstallmentHistoryScreen({ route, navigation }:
|
||||
const renderItem = ({ item, index }: { item: any, index: number }) => (
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.leftSide}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
|
||||
<Ionicons
|
||||
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.warning, 0.08) }]}>
|
||||
<Icon
|
||||
name={item.status === 'paid' ? 'checkmark-circle' : 'time'}
|
||||
size={20}
|
||||
color={item.status === 'paid' ? theme.colors.success : theme.colors.warning}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, FlatList, RefreshControl, TextInput, Pressable, Text
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
@@ -67,7 +67,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
|
||||
{/* Search Bar */}
|
||||
<View style={styles.headerArea}>
|
||||
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
|
||||
<Icon name="search-outline" size={20} color={theme.colors.textMuted} />
|
||||
<TextInput
|
||||
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
|
||||
placeholder="Search by name or phone..."
|
||||
@@ -77,7 +77,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => handleSearch('')} style={styles.clearBtn}>
|
||||
<Ionicons name="close-circle" size={16} color={theme.colors.textMuted} />
|
||||
<Icon name="close-circle" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -167,7 +167,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
|
||||
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('AddEditCustomer', {})}
|
||||
>
|
||||
<Ionicons name="add" size={24} color={theme.colors.white} />
|
||||
<Icon name="add" size={24} color={theme.colors.white} />
|
||||
</Pressable>
|
||||
</FeatureGate>
|
||||
</ScreenShell>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, FlatList, RefreshControl, Pressable } from 'rea
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
|
||||
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
|
||||
|
||||
@@ -75,13 +75,13 @@ const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selecte
|
||||
style={[
|
||||
styles.modeChip,
|
||||
{
|
||||
backgroundColor: mode === m.id ? `${theme.colors.primary}15` : theme.colors.surface,
|
||||
backgroundColor: mode === m.id ? theme.tint(theme.colors.primary, 0.08) : theme.colors.surface,
|
||||
borderColor: mode === m.id ? theme.colors.primary : theme.colors.cardBorder,
|
||||
}
|
||||
]}
|
||||
onPress={() => setMode(m.id)}
|
||||
>
|
||||
<Ionicons name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
|
||||
<Icon name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
|
||||
<Text style={[styles.modeText, { color: mode === m.id ? theme.colors.primary : theme.colors.textMuted }]}>{m.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -164,7 +164,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
|
||||
|
||||
{offlineQueue.length > 0 && (
|
||||
<View style={[styles.offlineBanner, { backgroundColor: theme.colors.warning }]}>
|
||||
<Ionicons name="cloud-offline" size={16} color="#fff" />
|
||||
<Icon name="cloud-offline" size={16} color="#fff" />
|
||||
<Text style={styles.offlineText}>{offlineQueue.length} entries waiting to sync</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -244,7 +244,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
|
||||
onPress={() => handleEntryPress(item)}
|
||||
>
|
||||
<View style={styles.entryLeft}>
|
||||
<View style={[styles.avatarBox, { backgroundColor: `${theme.colors.primary}15` }]}>
|
||||
<View style={[styles.avatarBox, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
|
||||
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.customerName.charAt(0)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
@@ -257,7 +257,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
|
||||
{isCollected || isPartial ? (
|
||||
<>
|
||||
<Text style={[styles.entryAmount, { color: theme.colors.success }]}>{formatAgentCurrency(item.collectedAmount)}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
|
||||
<Text style={[styles.statusText, { color: theme.colors.success }]}>Collected</Text>
|
||||
</View>
|
||||
</>
|
||||
@@ -280,6 +280,13 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
|
||||
index={-1}
|
||||
snapPoints={['50%', '65%']}
|
||||
enablePanDownToClose
|
||||
// The amount field sits in this sheet. Android's window shrinks for
|
||||
// the keyboard (adjustResize, restored on 15+ by MainActivity), and the
|
||||
// sheet has to be told so — its default assumes it must lift itself,
|
||||
// which on a resized window lifts it twice and off the top.
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
backgroundStyle={{ backgroundColor: theme.colors.surface }}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.cardBorder }}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, RefreshControl } from 'react-native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { SlideInRight } from 'react-native-reanimated';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
@@ -32,10 +32,10 @@ export default function LoanRequestListScreen({ navigation }: { navigation: Nati
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Recent Requests</Text>
|
||||
<Pressable
|
||||
style={[styles.newBtn, { backgroundColor: `${theme.colors.primary}15` }]}
|
||||
style={[styles.newBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}
|
||||
onPress={() => navigation.navigate('CreateLoanRequest', {})}
|
||||
>
|
||||
<Ionicons name="add" size={16} color={theme.colors.primary} />
|
||||
<Icon name="add" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.newBtnText, { color: theme.colors.primary }]}>New Request</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -70,7 +70,7 @@ export default function LoanRequestListScreen({ navigation }: { navigation: Nati
|
||||
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
|
||||
<Text style={[styles.category, { color: theme.colors.textMuted }]}>{item.category} Loan</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
|
||||
<View style={[styles.statusBadge, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.replace('_', ' ').toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
@@ -59,11 +59,11 @@ export default function SmsHistoryScreen({ navigation }: Props) {
|
||||
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Ionicons name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
|
||||
<Icon name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.phone, { color: theme.colors.textPrimary }]}>{item.customerPhone}</Text>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<Ionicons name={statusIcon} size={14} color={statusColor} />
|
||||
<Icon name={statusIcon} size={14} color={statusColor} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
@@ -58,13 +58,13 @@ const BiometricNodeComponent = ({
|
||||
{/* 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
|
||||
<Icon
|
||||
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} />
|
||||
<Icon name="finger-print" size={52} color={theme.colors.onBrandPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -114,14 +114,14 @@ const BiometricNodeComponent = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="finger-print" size={20} color={theme.colors.primary} />
|
||||
<Icon 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} />
|
||||
<Icon name="chevron-forward" size={18} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
@@ -152,7 +152,7 @@ export const CodeInput = ({
|
||||
|
||||
{error ? (
|
||||
<View style={styles.message}>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="alert-circle"
|
||||
size={14}
|
||||
color={onBrand ? theme.colors.onBrandPrimary : theme.colors.danger}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { memo } from 'react';
|
||||
import Svg, {
|
||||
Circle,
|
||||
Defs,
|
||||
G,
|
||||
LinearGradient,
|
||||
Path,
|
||||
Rect,
|
||||
Stop,
|
||||
} from 'react-native-svg';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
/**
|
||||
* The three onboarding illustrations, drawn in SVG from theme roles.
|
||||
*
|
||||
* Vector rather than bitmap art so they follow the live theme — the same
|
||||
* drawing is correct in light and dark mode — cost a few kilobytes instead of
|
||||
* three large PNGs, and stay sharp at any density. Every fill is a theme role;
|
||||
* no hex is written here.
|
||||
*
|
||||
* All three share one 280 × 260 canvas and a soft tint disc behind the
|
||||
* subject, so the slides read as a set.
|
||||
*/
|
||||
|
||||
const VIEW_BOX = '0 0 280 260';
|
||||
|
||||
interface IllustrationProps {
|
||||
/** Rendered width. Height follows the shared aspect ratio. */
|
||||
width: number;
|
||||
}
|
||||
|
||||
const heightFor = (width: number) => (width * 260) / 280;
|
||||
|
||||
/** The tint disc and a scatter of small sparkles every slide sits on. */
|
||||
const Backdrop = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<G>
|
||||
<Circle cx={140} cy={132} r={112} fill={theme.colors.primarySurface} />
|
||||
<Circle cx={140} cy={132} r={84} fill={theme.colors.surface} opacity={0.55} />
|
||||
<Circle cx={42} cy={64} r={4} fill={theme.colors.primaryLight} opacity={0.6} />
|
||||
<Circle cx={244} cy={48} r={3} fill={theme.colors.accentFill} />
|
||||
<Circle cx={252} cy={196} r={5} fill={theme.colors.primaryLight} opacity={0.45} />
|
||||
<Circle cx={30} cy={200} r={3} fill={theme.colors.accentFill} opacity={0.8} />
|
||||
</G>
|
||||
);
|
||||
};
|
||||
|
||||
/** Slide 1 — "Your goals. Our support.": two cards and a phone with a chart. */
|
||||
const GoalsIllustrationComponent = ({ width }: IllustrationProps) => {
|
||||
const theme = useTheme();
|
||||
const [heroA, heroB, heroC] = theme.colors.heroGradient;
|
||||
|
||||
return (
|
||||
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
|
||||
<Defs>
|
||||
<LinearGradient id="cardFront" x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={heroA} />
|
||||
<Stop offset="1" stopColor={heroC} />
|
||||
</LinearGradient>
|
||||
<LinearGradient id="cardBack" x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={theme.colors.primaryLight} />
|
||||
<Stop offset="1" stopColor={heroB} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
<Backdrop />
|
||||
|
||||
{/* back card, tilted */}
|
||||
<G transform="rotate(-14 98 112)">
|
||||
<Rect x={30} y={70} width={150} height={94} rx={14} fill="url(#cardBack)" />
|
||||
</G>
|
||||
|
||||
{/* front card */}
|
||||
<G transform="rotate(-4 104 150)">
|
||||
<Rect x={34} y={104} width={150} height={94} rx={14} fill="url(#cardFront)" />
|
||||
<Rect x={50} y={124} width={26} height={20} rx={4} fill={theme.colors.accentFill} />
|
||||
<Rect x={50} y={160} width={70} height={7} rx={3.5} fill={theme.colors.onBrandSurfaceStrong} />
|
||||
<Rect x={50} y={174} width={44} height={7} rx={3.5} fill={theme.colors.onBrandSurface} />
|
||||
<Circle cx={158} cy={176} r={9} fill={theme.colors.onBrandSurfaceStrong} />
|
||||
<Circle cx={146} cy={176} r={9} fill={theme.colors.onBrandSurface} />
|
||||
</G>
|
||||
|
||||
{/* phone */}
|
||||
<Rect x={164} y={58} width={84} height={156} rx={16} fill={theme.colors.surface} stroke={theme.colors.cardBorderStrong} strokeWidth={2} />
|
||||
<Rect x={172} y={70} width={68} height={132} rx={10} fill={theme.colors.primarySurface} />
|
||||
<Rect x={198} y={63} width={16} height={3} rx={1.5} fill={theme.colors.cardBorderStrong} />
|
||||
{/* chart bars */}
|
||||
<Rect x={182} y={150} width={10} height={36} rx={3} fill={theme.colors.primaryLight} />
|
||||
<Rect x={198} y={134} width={10} height={52} rx={3} fill={theme.colors.primary} />
|
||||
<Rect x={214} y={118} width={10} height={68} rx={3} fill={theme.colors.primaryDark} />
|
||||
{/* trend line */}
|
||||
<Path d="M182 120 L200 106 L214 112 L230 88" stroke={theme.colors.primary} strokeWidth={3} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Circle cx={230} cy={88} r={5} fill={theme.colors.accentFill} />
|
||||
{/* check badge */}
|
||||
<Circle cx={240} cy={204} r={16} fill={theme.colors.success} />
|
||||
<Path d="M232 204 L238 210 L249 198" stroke={theme.colors.textOnBrand} strokeWidth={3} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** Slide 2 — "Secure & convenient": a phone carrying a shield. */
|
||||
const SecureIllustrationComponent = ({ width }: IllustrationProps) => {
|
||||
const theme = useTheme();
|
||||
const [heroA, , heroC] = theme.colors.heroGradient;
|
||||
|
||||
return (
|
||||
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
|
||||
<Defs>
|
||||
<LinearGradient id="phoneBody" x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={heroA} />
|
||||
<Stop offset="1" stopColor={heroC} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
<Backdrop />
|
||||
|
||||
{/* phone */}
|
||||
<Rect x={92} y={34} width={104} height={196} rx={20} fill="url(#phoneBody)" />
|
||||
<Rect x={101} y={46} width={86} height={172} rx={13} fill={theme.colors.onBrandSurface} />
|
||||
<Rect x={132} y={39} width={24} height={4} rx={2} fill={theme.colors.onBrandSurfaceStrong} />
|
||||
|
||||
{/* shield on the screen */}
|
||||
<Path
|
||||
d="M144 84 L174 96 V120 C174 142 161 156 144 164 C127 156 114 142 114 120 V96 Z"
|
||||
fill={theme.colors.surface}
|
||||
/>
|
||||
<Path
|
||||
d="M131 123 L141 133 L159 113"
|
||||
stroke={theme.colors.primary}
|
||||
strokeWidth={6}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Rect x={118} y={184} width={52} height={7} rx={3.5} fill={theme.colors.onBrandSurfaceStrong} />
|
||||
<Rect x={128} y={198} width={32} height={7} rx={3.5} fill={theme.colors.onBrandSurface} />
|
||||
|
||||
{/* small shield, right */}
|
||||
<Path
|
||||
d="M222 110 L242 118 V134 C242 149 233 158 222 163 C211 158 202 149 202 134 V118 Z"
|
||||
fill={theme.colors.surface}
|
||||
stroke={theme.colors.primary}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Path d="M214 136 L220 142 L231 130" stroke={theme.colors.primary} strokeWidth={3.5} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
||||
{/* a person, left — the customer the shield is for */}
|
||||
<Circle cx={62} cy={168} r={9} fill={theme.colors.primaryDark} />
|
||||
<Path d="M48 214 C48 194 54 182 62 182 C70 182 76 194 76 214 Z" fill={theme.colors.primary} />
|
||||
|
||||
{/* lock, top left */}
|
||||
<Rect x={50} y={88} width={28} height={22} rx={5} fill={theme.colors.accentFill} />
|
||||
<Path d="M56 88 V82 C56 74 72 74 72 82 V88" stroke={theme.colors.accentFill} strokeWidth={4} fill="none" />
|
||||
<Circle cx={64} cy={99} r={3} fill={theme.colors.textOnAccentFill} />
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** Slide 3 — "Build a brighter financial future": a gift box. */
|
||||
const FutureIllustrationComponent = ({ width }: IllustrationProps) => {
|
||||
const theme = useTheme();
|
||||
const [heroA, heroB, heroC] = theme.colors.heroGradient;
|
||||
const ribbon = theme.colors.accentFill;
|
||||
|
||||
return (
|
||||
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
|
||||
<Defs>
|
||||
<LinearGradient id="boxBody" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={heroB} />
|
||||
<Stop offset="1" stopColor={heroC} />
|
||||
</LinearGradient>
|
||||
<LinearGradient id="boxLid" x1="0" y1="0" x2="1" y2="0">
|
||||
<Stop offset="0" stopColor={theme.colors.primaryLight} />
|
||||
<Stop offset="1" stopColor={heroA} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
<Backdrop />
|
||||
|
||||
{/* shadow */}
|
||||
<Rect x={74} y={214} width={132} height={10} rx={5} fill={theme.colors.primaryBorder} opacity={0.7} />
|
||||
|
||||
{/* box */}
|
||||
<Rect x={82} y={122} width={116} height={96} rx={8} fill="url(#boxBody)" />
|
||||
<Rect x={130} y={122} width={20} height={96} fill={ribbon} />
|
||||
|
||||
{/* lid */}
|
||||
<Rect x={72} y={98} width={136} height={32} rx={8} fill="url(#boxLid)" />
|
||||
<Rect x={130} y={98} width={20} height={32} fill={ribbon} />
|
||||
|
||||
{/* bow */}
|
||||
<Path d="M140 98 C120 70 92 78 104 94 C110 102 126 100 140 98 Z" fill={ribbon} />
|
||||
<Path d="M140 98 C160 70 188 78 176 94 C170 102 154 100 140 98 Z" fill={ribbon} />
|
||||
<Circle cx={140} cy={97} r={8} fill={theme.colors.accent} />
|
||||
|
||||
{/* floating coins */}
|
||||
<Circle cx={60} cy={110} r={13} fill={ribbon} />
|
||||
<Circle cx={60} cy={110} r={8} fill="none" stroke={theme.colors.textOnAccentFill} strokeWidth={2} opacity={0.5} />
|
||||
<Circle cx={226} cy={86} r={10} fill={ribbon} />
|
||||
<Circle cx={226} cy={86} r={6} fill="none" stroke={theme.colors.textOnAccentFill} strokeWidth={2} opacity={0.5} />
|
||||
|
||||
{/* rising arrow */}
|
||||
<Path d="M214 178 L238 150" stroke={theme.colors.success} strokeWidth={4} strokeLinecap="round" />
|
||||
<Path d="M226 148 L240 148 L240 162" stroke={theme.colors.success} strokeWidth={4} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GoalsIllustration = memo(GoalsIllustrationComponent);
|
||||
export const SecureIllustration = memo(SecureIllustrationComponent);
|
||||
export const FutureIllustration = memo(FutureIllustrationComponent);
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { haptics } from '../../../services/haptics/haptics';
|
||||
@@ -8,7 +8,7 @@ import { appConfig } from '../../../config/appConfig';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
|
||||
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IoniconName = React.ComponentProps<typeof Icon>['name'];
|
||||
|
||||
interface PreLoginAction {
|
||||
id: string;
|
||||
@@ -96,7 +96,7 @@ export const PreLoginActions = ({ onOpenAccount }: PreLoginActionsProps) => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={action.icon} size={20} color={theme.colors.onBrandPrimary} />
|
||||
<Icon 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.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type DimensionValue,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Svg, { Path, Rect } from 'react-native-svg';
|
||||
|
||||
import { fontFamily, useTheme } from '../../../theme';
|
||||
@@ -257,7 +257,7 @@ const SimCard = ({
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
) : (
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={number ? 'arrow-forward' : 'close-circle-outline'}
|
||||
size={18}
|
||||
color={accent}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
@@ -149,7 +149,7 @@ export default function CreateMpinScreen({ navigation }: Props) {
|
||||
{biometricsAvailable && (
|
||||
<Card style={styles.biometricCard}>
|
||||
<View style={styles.biometricRow}>
|
||||
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
|
||||
<Icon 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
|
||||
@@ -171,8 +171,8 @@ export default function CreateMpinScreen({ navigation }: Props) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
|
||||
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
|
||||
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.warning, 0.08) }]}>
|
||||
<Icon 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, InfoBanner, Input, Loader } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
@@ -479,11 +479,12 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* White fill, navy label — `primary` here would be navy on
|
||||
navy and disappear into the wash. */}
|
||||
{/* Solid brand blue on the navy wash. The wash is kept deep
|
||||
precisely so the vivid blue reads against it, and the
|
||||
label is the fill's own `textOnBrand` in both themes. */}
|
||||
<Button
|
||||
title="Sign in"
|
||||
variant="inverse"
|
||||
variant="primary"
|
||||
loading={isLoading}
|
||||
disabled={!canSubmit}
|
||||
onPress={handleSignIn}
|
||||
@@ -518,7 +519,7 @@ export default function LoginScreen({ navigation }: Props) {
|
||||
onPress={handleUseMpin}
|
||||
disabled={isLoading}
|
||||
rightAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
@@ -38,8 +38,8 @@ export default function MpinLockedScreen({ navigation }: Props) {
|
||||
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 style={[styles.iconWrap, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
|
||||
<Icon name="lock-closed-outline" size={40} color={theme.colors.error} />
|
||||
</View>
|
||||
|
||||
<Card style={styles.card}>
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated as RNAnimated,
|
||||
Easing as RNEasing,
|
||||
FlatList,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
FadeInDown,
|
||||
type SharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { Button } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useFeature } from '../../../features';
|
||||
import { haptics } from '../../../services/haptics/haptics';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
import { selectLastProfile } from '../store/authSelectors';
|
||||
import { markOnboardingSeen } from '../utils/onboardingFlag';
|
||||
import {
|
||||
FutureIllustration,
|
||||
GoalsIllustration,
|
||||
SecureIllustration,
|
||||
} from '../components/OnboardingIllustrations';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Onboarding'>>;
|
||||
|
||||
type IconName = React.ComponentProps<typeof Icon>['name'];
|
||||
|
||||
const GUTTER = 24;
|
||||
/** The illustrations are drawn on a 280 × 260 canvas. */
|
||||
const ART_RATIO = 280 / 260;
|
||||
|
||||
/**
|
||||
* The slides. Every line describes something the app actually does — accounts
|
||||
* and transfers, the security around them, and the products behind Explore —
|
||||
* rather than generic marketing. Nothing here claims a feature the app lacks.
|
||||
*
|
||||
* `highlights` are kept to a few words each so the chips sit in one or two
|
||||
* rows on a 360pt phone. `rolling` is a short lead and a few words that roll
|
||||
* into place after it, each kept short enough for the line to fit one row.
|
||||
*/
|
||||
const SLIDES: ReadonlyArray<{
|
||||
key: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
body: string;
|
||||
highlights: ReadonlyArray<{ icon: IconName; label: string }>;
|
||||
rolling: { lead: string; words: readonly string[] };
|
||||
Illustration: React.ComponentType<{ width: number }>;
|
||||
}> = [
|
||||
{
|
||||
key: 'goals',
|
||||
kicker: 'Everyday banking',
|
||||
title: 'Your goals.\nOur support.',
|
||||
body: 'Check balances, read your mini statement and move money between banks — all from one app.',
|
||||
highlights: [
|
||||
{ icon: 'wallet-outline', label: 'Live balances' },
|
||||
{ icon: 'receipt-outline', label: 'Statements' },
|
||||
{ icon: 'paper-plane-outline', label: 'IMPS transfers' },
|
||||
],
|
||||
rolling: { lead: 'Banking made', words: ['simple', 'instant', 'personal'] },
|
||||
Illustration: GoalsIllustration,
|
||||
},
|
||||
{
|
||||
key: 'secure',
|
||||
kicker: 'Security first',
|
||||
title: 'Secure\n& convenient',
|
||||
body: 'Your MPIN, biometrics and a phone registered to you. Every transfer is confirmed with a TPIN and an OTP.',
|
||||
highlights: [
|
||||
{ icon: 'finger-print-outline', label: 'Biometric login' },
|
||||
{ icon: 'phone-portrait-outline', label: 'Device bound' },
|
||||
{ icon: 'key-outline', label: 'TPIN + OTP' },
|
||||
],
|
||||
rolling: { lead: 'Protected by', words: ['your MPIN', 'biometrics', 'TPIN + OTP'] },
|
||||
Illustration: SecureIllustration,
|
||||
},
|
||||
{
|
||||
key: 'future',
|
||||
kicker: 'Grow with us',
|
||||
title: 'Build a brighter\nfinancial future',
|
||||
body: 'Open fixed and recurring deposits, plan a loan with the EMI calculator and explore insurance.',
|
||||
highlights: [
|
||||
{ icon: 'trending-up-outline', label: 'FD & RD' },
|
||||
{ icon: 'calculator-outline', label: 'EMI planner' },
|
||||
{ icon: 'shield-checkmark-outline', label: 'Insurance' },
|
||||
],
|
||||
rolling: { lead: 'Your future, made', words: ['secure', 'simple', 'brighter'] },
|
||||
Illustration: FutureIllustration,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* First-launch introduction: three swipeable slides, shown once per handset.
|
||||
*
|
||||
* It only ever leads somewhere — there is nothing to fill in — so both
|
||||
* "Get started" and "Skip" end in the same place: the flag is recorded and the
|
||||
* stack is replaced with registration (or sign-in, when registration is off or
|
||||
* a profile is already stored), so the back gesture cannot return here.
|
||||
*
|
||||
* Motion is tied to the swipe itself: the copy drifts and fades, the
|
||||
* illustration scales, and the progress dots stretch, all driven on the UI
|
||||
* thread from the scroll position. With "reduce motion" on, only the swipe
|
||||
* itself moves.
|
||||
*/
|
||||
export default function OnboardingScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const listRef = useRef<FlatList<(typeof SLIDES)[number]>>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const scrollX = useSharedValue(0);
|
||||
|
||||
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
|
||||
const lastProfile = useAppSelector(selectLastProfile);
|
||||
const isLast = index === SLIDES.length - 1;
|
||||
|
||||
const finish = useCallback(() => {
|
||||
void markOnboardingSeen();
|
||||
navigation.replace(registrationEnabled && !lastProfile ? 'Register' : 'Login');
|
||||
}, [lastProfile, navigation, registrationEnabled]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
haptics.selection();
|
||||
|
||||
if (isLast) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
listRef.current?.scrollToIndex({ index: index + 1, animated: true });
|
||||
}, [finish, index, isLast]);
|
||||
|
||||
const handleScroll = useAnimatedScrollHandler((event) => {
|
||||
scrollX.value = event.contentOffset.x;
|
||||
});
|
||||
|
||||
const handleScrollEnd = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const next = Math.round(event.nativeEvent.contentOffset.x / width);
|
||||
|
||||
setIndex(Math.min(Math.max(next, 0), SLIDES.length - 1));
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
{/* A soft tint rising from the foot of the screen, so the white slides
|
||||
sit on something rather than floating in a blank page. */}
|
||||
<LinearGradient
|
||||
colors={[theme.colors.surface, theme.colors.surface, theme.colors.primarySurface]}
|
||||
locations={[0, 0.55, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
|
||||
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
|
||||
|
||||
{/* ── Top bar: the way out ────────────────────────────────────────── */}
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Skip introduction"
|
||||
disabled={isLast}
|
||||
onPress={finish}
|
||||
hitSlop={10}
|
||||
style={[styles.skip, isLast && styles.hidden]}
|
||||
>
|
||||
<Text style={[theme.text.button, { color: theme.colors.primary }]}>Skip</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Animated.FlatList
|
||||
ref={listRef}
|
||||
data={SLIDES}
|
||||
keyExtractor={(item) => item.key}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
bounces={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
getItemLayout={(_, i) => ({ length: width, offset: width * i, index: i })}
|
||||
renderItem={({ item, index: slideIndex }) => (
|
||||
<Slide
|
||||
item={item}
|
||||
slideIndex={slideIndex}
|
||||
width={width}
|
||||
scrollX={scrollX}
|
||||
active={slideIndex === index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ── Footer: progress, and the way on ──────────────────────────────── */}
|
||||
<View style={styles.footer}>
|
||||
<View
|
||||
style={styles.dots}
|
||||
accessible
|
||||
accessibilityLabel={`Slide ${index + 1} of ${SLIDES.length}`}
|
||||
>
|
||||
{SLIDES.map((entry, i) => (
|
||||
<Dot key={entry.key} i={i} width={width} scrollX={scrollX} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={isLast ? 'Get started' : 'Next'}
|
||||
onPress={handleNext}
|
||||
rightAccessory={
|
||||
<Icon
|
||||
name={isLast ? 'checkmark' : 'arrow-forward'}
|
||||
size={18}
|
||||
color={theme.colors.textOnBrand}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── One slide ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface SlideProps {
|
||||
item: (typeof SLIDES)[number];
|
||||
slideIndex: number;
|
||||
width: number;
|
||||
scrollX: SharedValue<number>;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const SlideComponent = ({ item, slideIndex, width, scrollX, active }: SlideProps) => {
|
||||
const theme = useTheme();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [artBox, setArtBox] = useState({ width: 0, height: 0 });
|
||||
const float = useSharedValue(0);
|
||||
/** Bumped each time this slide arrives, to replay the copy's entrance. */
|
||||
const [run, setRun] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setRun((n) => n + 1);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
/** Rise-and-fade, staggered by position. Nothing at all with reduce motion. */
|
||||
const enter = (position: number) =>
|
||||
reduceMotion ? undefined : FadeInDown.delay(80 + position * 90).duration(420);
|
||||
|
||||
// A slow bob on the slide that is showing. Stopped when it is not, and never
|
||||
// started with reduce motion on.
|
||||
useEffect(() => {
|
||||
if (!active || reduceMotion) {
|
||||
cancelAnimation(float);
|
||||
float.value = withTiming(0, { duration: 200 });
|
||||
return;
|
||||
}
|
||||
|
||||
float.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(-8, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
|
||||
),
|
||||
-1,
|
||||
);
|
||||
|
||||
return () => cancelAnimation(float);
|
||||
}, [active, float, reduceMotion]);
|
||||
|
||||
const range = [(slideIndex - 1) * width, slideIndex * width, (slideIndex + 1) * width];
|
||||
|
||||
// The copy drifts a little against the swipe and fades, so it reads as a
|
||||
// layer above the page rather than glued to it.
|
||||
const copyStyle = useAnimatedStyle(() => {
|
||||
if (reduceMotion) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: interpolate(scrollX.value, range, [0, 1, 0], Extrapolation.CLAMP),
|
||||
transform: [
|
||||
{
|
||||
translateX: interpolate(
|
||||
scrollX.value,
|
||||
range,
|
||||
[width * 0.3, 0, -width * 0.3],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const artStyle = useAnimatedStyle(() => {
|
||||
const scale = reduceMotion
|
||||
? 1
|
||||
: interpolate(scrollX.value, range, [0.82, 1, 0.82], Extrapolation.CLAMP);
|
||||
|
||||
return {
|
||||
opacity: reduceMotion
|
||||
? 1
|
||||
: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
|
||||
transform: [{ translateY: float.value }, { scale }],
|
||||
};
|
||||
});
|
||||
|
||||
const handleArtLayout = (event: LayoutChangeEvent) => {
|
||||
const { width: boxWidth, height: boxHeight } = event.nativeEvent.layout;
|
||||
setArtBox({ width: boxWidth, height: boxHeight });
|
||||
};
|
||||
|
||||
// Fits whatever room the copy leaves: by width on a tall phone, by height
|
||||
// on a short one, and never larger than the art was drawn to look good at.
|
||||
const artWidth = Math.max(
|
||||
0,
|
||||
Math.min(artBox.width, (artBox.height - 16) * ART_RATIO, 330),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.slide, { width }]}>
|
||||
<Animated.View style={[styles.copy, copyStyle]}>
|
||||
{/* Re-keyed on every arrival, so each element replays its entrance —
|
||||
label, headline, rolling line, description, chips — one after
|
||||
another. */}
|
||||
<Animated.View key={`kicker-${run}`} entering={enter(0)} style={styles.kickerRow}>
|
||||
<Text style={[theme.text.eyebrow, styles.kickerText, { color: theme.colors.primary }]}>
|
||||
{item.kicker}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View key={`title-${run}`} entering={enter(1)}>
|
||||
<Text style={[theme.text.display, { color: theme.colors.textPrimary }]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View key={`roll-${run}`} entering={enter(2)}>
|
||||
<RollingWord lead={item.rolling.lead} words={item.rolling.words} active={active} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View key={`body-${run}`} entering={enter(3)}>
|
||||
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{item.body}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View key={`chips-${run}`} entering={enter(4)} style={styles.chips}>
|
||||
{item.highlights.map((highlight) => (
|
||||
<View
|
||||
key={highlight.label}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: theme.colors.primarySurface,
|
||||
borderColor: theme.colors.primaryBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Icon name={highlight.icon} size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.chipText, { color: theme.colors.textPrimary }]}>
|
||||
{highlight.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.art} onLayout={handleArtLayout}>
|
||||
{artWidth > 0 ? (
|
||||
<Animated.View style={artStyle}>
|
||||
<item.Illustration width={artWidth} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Slide = memo(SlideComponent);
|
||||
|
||||
/* ── A lead with a word that rolls into place ──────────────────────────────── */
|
||||
|
||||
/**
|
||||
* "Banking made simple" → "instant" → "personal": the lead stays put and the
|
||||
* last word rolls — the old one lifts away as the new one rises in.
|
||||
*
|
||||
* The word sits in a clipped window sized to the longest of them, so the line
|
||||
* never changes width. Rolls only on the slide that is showing; screen
|
||||
* readers hear the lead and every word once. With reduce motion on, the first
|
||||
* word simply stays.
|
||||
*
|
||||
* Driven by React Native's own Animated on a View, NOT by Reanimated's
|
||||
* entering/exiting layout animations. Those, put on a Text, hit a Reanimated
|
||||
* Android bug (software-mansion/react-native-reanimated#10734, fixed by
|
||||
* #10732): the layout-animation proxy reads the Text's props as if they were
|
||||
* a View's, past the end of the allocation, and the app dies with SIGSEGV in
|
||||
* MountingCoordinator::pullTransaction — intermittently, depending on the
|
||||
* device's allocator. Swapping a keyed Animated.Text every 2.2 seconds made
|
||||
* this screen hit it constantly. Do not move this back to layout animations.
|
||||
*/
|
||||
const RollingWord = ({
|
||||
lead,
|
||||
words,
|
||||
active,
|
||||
}: {
|
||||
lead: string;
|
||||
words: readonly string[];
|
||||
active: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [wordIndex, setWordIndex] = useState(0);
|
||||
const longest = words.reduce((a, b) => (b.length > a.length ? b : a), '');
|
||||
/** -1 lifted away above, 0 in place, 1 waiting below. */
|
||||
const roll = useRef(new RNAnimated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
roll.stopAnimation();
|
||||
roll.setValue(0);
|
||||
setWordIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reduceMotion || words.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let alive = true;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
RNAnimated.timing(roll, {
|
||||
toValue: -1,
|
||||
duration: 220,
|
||||
easing: RNEasing.in(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (!alive || !finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap the word while it is out of sight, then bring it up from below.
|
||||
roll.setValue(1);
|
||||
setWordIndex((n) => (n + 1) % words.length);
|
||||
RNAnimated.timing(roll, {
|
||||
toValue: 0,
|
||||
duration: 320,
|
||||
easing: RNEasing.out(RNEasing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}, 2200);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
roll.stopAnimation();
|
||||
roll.setValue(0);
|
||||
};
|
||||
}, [active, reduceMotion, roll, words.length]);
|
||||
|
||||
const rollStyle = {
|
||||
opacity: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [0, 1, 0] }),
|
||||
transform: [
|
||||
{ translateY: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [-16, 0, 16] }) },
|
||||
],
|
||||
};
|
||||
|
||||
const wordStyle = [theme.text.h2, { color: theme.colors.primary }];
|
||||
|
||||
return (
|
||||
<View style={styles.rollRow} accessible accessibilityLabel={`${lead} ${words.join(', ')}`}>
|
||||
<Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>{`${lead} `}</Text>
|
||||
<View style={styles.rollWindow}>
|
||||
{/* Holds the width and height of the longest word. */}
|
||||
<Text style={[wordStyle, styles.ghost]}>{longest}</Text>
|
||||
<RNAnimated.View style={[styles.rollWord, rollStyle]}>
|
||||
<Text style={wordStyle}>{words[wordIndex]}</Text>
|
||||
</RNAnimated.View>
|
||||
<View style={[styles.rollUnderline, { backgroundColor: theme.colors.accentFill }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── One progress dot ──────────────────────────────────────────────────────── */
|
||||
|
||||
const Dot = ({
|
||||
i,
|
||||
width,
|
||||
scrollX,
|
||||
}: {
|
||||
i: number;
|
||||
width: number;
|
||||
scrollX: SharedValue<number>;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Stretches into a bar as its slide arrives, and is the brand blue at full
|
||||
// strength only there — shape and strength both carry it, not colour alone.
|
||||
const style = useAnimatedStyle(() => {
|
||||
const range = [(i - 1) * width, i * width, (i + 1) * width];
|
||||
|
||||
return {
|
||||
width: interpolate(scrollX.value, range, [8, 26, 8], Extrapolation.CLAMP),
|
||||
opacity: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
|
||||
};
|
||||
});
|
||||
|
||||
return <Animated.View style={[styles.dot, { backgroundColor: theme.colors.primary }, style]} />;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: GUTTER - 6,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
skip: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
|
||||
slide: {
|
||||
flex: 1,
|
||||
paddingHorizontal: GUTTER,
|
||||
paddingTop: 20,
|
||||
},
|
||||
copy: {
|
||||
gap: 12,
|
||||
},
|
||||
kickerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
kickerText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
chips: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
fontFamily: fontFamily.medium,
|
||||
},
|
||||
rollRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
rollWindow: {
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 6,
|
||||
},
|
||||
ghost: {
|
||||
opacity: 0,
|
||||
},
|
||||
rollWord: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
rollUnderline: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
},
|
||||
art: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
footer: {
|
||||
paddingHorizontal: GUTTER,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 8,
|
||||
gap: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dots: {
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
dot: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} 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 { Icon } from '../../../components/Icon';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
@@ -417,7 +417,7 @@ export default function RegisterScreen({ navigation }: Props) {
|
||||
accessibilityLabel="Go back"
|
||||
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
|
||||
<Icon name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -451,7 +451,7 @@ export default function RegisterScreen({ navigation }: Props) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
|
||||
<Icon 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. */}
|
||||
@@ -516,7 +516,7 @@ export default function RegisterScreen({ navigation }: Props) {
|
||||
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={needsSettings ? 'settings-outline' : blocker.icon}
|
||||
size={20}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { StatusBar, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Svg, { G, Path, Rect } from 'react-native-svg';
|
||||
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
|
||||
/**
|
||||
* The skyline, on a 400 × 220 canvas anchored to the bottom edge.
|
||||
*
|
||||
* Two layers of towers: a far row drawn in a translucent white, so it reads as
|
||||
* haze against whatever navy the theme supplies, and a near row in the wash's
|
||||
* darkest stop, with lit windows. Fixed geometry rather than random, so the
|
||||
* splash is the same picture on every launch.
|
||||
*/
|
||||
const FAR: ReadonlyArray<[x: number, w: number, h: number]> = [
|
||||
[0, 34, 92], [30, 26, 120], [52, 40, 84], [88, 22, 140], [106, 36, 104],
|
||||
[140, 30, 156], [196, 24, 128], [224, 42, 96], [262, 28, 150], [286, 38, 110],
|
||||
[320, 24, 132], [340, 36, 88], [372, 28, 118],
|
||||
];
|
||||
|
||||
const NEAR: ReadonlyArray<[x: number, w: number, h: number]> = [
|
||||
[-4, 40, 64], [32, 30, 96], [60, 46, 72], [104, 28, 112], [150, 32, 128],
|
||||
[210, 30, 104], [238, 44, 70], [280, 32, 118], [310, 40, 80], [348, 30, 100],
|
||||
[376, 30, 66],
|
||||
];
|
||||
|
||||
const CANVAS_W = 400;
|
||||
const CANVAS_H = 220;
|
||||
|
||||
/** Lit windows for one near tower — a deterministic sprinkle, not a grid. */
|
||||
const windowsFor = (x: number, w: number, h: number, seed: number) => {
|
||||
const lit: Array<{ x: number; y: number }> = [];
|
||||
const columns = Math.max(1, Math.floor((w - 8) / 8));
|
||||
|
||||
for (let row = 0; row * 10 + 14 < h - 6; row += 1) {
|
||||
for (let col = 0; col < columns; col += 1) {
|
||||
if ((row * 5 + col * 3 + seed) % 4 === 0) {
|
||||
lit.push({ x: x + 6 + col * 8, y: CANVAS_H - h + 10 + row * 10 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lit;
|
||||
};
|
||||
|
||||
const Skyline = memo(({ width }: { width: number }) => {
|
||||
const theme = useTheme();
|
||||
const nearColor = theme.colors.brandGradient[2];
|
||||
|
||||
const windows = useMemo(
|
||||
() => NEAR.flatMap(([x, w, h], i) => windowsFor(x, w, h, i)),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Svg
|
||||
width={width}
|
||||
height={(width * CANVAS_H) / CANVAS_W}
|
||||
viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
|
||||
preserveAspectRatio="xMidYMax slice"
|
||||
>
|
||||
{/* far row */}
|
||||
<G fill={theme.colors.onBrandSurface}>
|
||||
{FAR.map(([x, w, h]) => (
|
||||
<Rect key={`f${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
|
||||
))}
|
||||
{/* the landmark tower, with its spire */}
|
||||
<Rect x={170} y={CANVAS_H - 188} width={26} height={188} />
|
||||
<Path d={`M176 ${CANVAS_H - 188} L183 ${CANVAS_H - 214} L190 ${CANVAS_H - 188} Z`} />
|
||||
</G>
|
||||
|
||||
{/* near row */}
|
||||
<G fill={nearColor}>
|
||||
{NEAR.map(([x, w, h]) => (
|
||||
<Rect key={`n${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
|
||||
))}
|
||||
</G>
|
||||
|
||||
{/* windows */}
|
||||
<G fill={theme.colors.accentFill} opacity={0.75}>
|
||||
{windows.map((pane) => (
|
||||
<Rect key={`${pane.x}-${pane.y}`} x={pane.x} y={pane.y} width={3} height={4} />
|
||||
))}
|
||||
</G>
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The launch screen, shown while the app restores its session and resolves
|
||||
* its feature flags.
|
||||
*
|
||||
* The navy wash with the bank's logo on its white plate, a one-line promise,
|
||||
* and the skyline along the foot. Light status-bar icons in both themes, since
|
||||
* the wash is dark in both.
|
||||
*/
|
||||
const SplashScreenComponent = () => {
|
||||
const theme = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<LinearGradient
|
||||
colors={theme.colors.brandGradient}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.skyline} pointerEvents="none">
|
||||
<Skyline width={width} />
|
||||
</View>
|
||||
|
||||
<View style={styles.centre}>
|
||||
<Animated.View entering={FadeIn.duration(500)}>
|
||||
<BrandLogo width={210} />
|
||||
</Animated.View>
|
||||
<Animated.View entering={FadeInUp.delay(250).duration(500)}>
|
||||
<Text style={[theme.text.body, styles.tagline, { color: theme.colors.onBrandSecondary }]}>
|
||||
Banking for a brighter tomorrow
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
centre: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 18,
|
||||
// Sits the lock-up a little above centre, clear of the skyline.
|
||||
paddingBottom: '30%',
|
||||
},
|
||||
tagline: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
skyline: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const SplashScreen = memo(SplashScreenComponent);
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
@@ -227,8 +227,8 @@ export default function VerifyOtpScreen({ navigation }: Props) {
|
||||
</Card>
|
||||
|
||||
{registration.accountsLinked > 0 && (
|
||||
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
|
||||
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
|
||||
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.success, 0.05) }]}>
|
||||
<Icon 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.'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/**
|
||||
* Whether this handset has already shown the first-launch introduction.
|
||||
*
|
||||
* A device fact, not a customer one, so it is one of the keys sign-out keeps —
|
||||
* see `KEPT_KEYS` in `appDataPurge.ts`. Renaming it here means renaming it
|
||||
* there, or every sign-out replays the slides.
|
||||
*/
|
||||
export const ONBOARDING_SEEN_KEY = 'bankcbs.onboarding.seen';
|
||||
|
||||
/**
|
||||
* Reads the flag. A storage failure answers "seen": the slides are a welcome,
|
||||
* not a gate, and a handset whose storage is misbehaving is better off going
|
||||
* straight to sign-in than being shown them on every launch.
|
||||
*/
|
||||
export const readOnboardingSeen = async (): Promise<boolean> => {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(ONBOARDING_SEEN_KEY)) === '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/** Best effort. Failing to record it costs one more viewing, nothing else. */
|
||||
export const markOnboardingSeen = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem(ONBOARDING_SEEN_KEY, '1');
|
||||
} catch {
|
||||
// Deliberately ignored — see above.
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { AwarenessArticle } from '../types';
|
||||
@@ -24,8 +24,8 @@ export const AwarenessCard = ({
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name={article.icon as any} size={22} color={theme.colors.primary} />
|
||||
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name={article.icon as any} size={22} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.headerInfo}>
|
||||
<View style={styles.titleRow}>
|
||||
@@ -33,7 +33,7 @@ export const AwarenessCard = ({
|
||||
{article.category}
|
||||
</Text>
|
||||
{article.isUrgent && (
|
||||
<View style={[styles.urgentBadge, { backgroundColor: `${theme.colors.error}15` }]}>
|
||||
<View style={[styles.urgentBadge, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
|
||||
<Text style={[styles.urgentText, { color: theme.colors.error }]}>URGENT</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -43,7 +43,7 @@ export const AwarenessCard = ({
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable hitSlop={10} onPress={onToggleBookmark} style={styles.bookmarkBtn}>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
|
||||
size={22}
|
||||
color={isBookmarked ? theme.colors.primary : theme.colors.textMuted}
|
||||
@@ -65,7 +65,7 @@ export const AwarenessCard = ({
|
||||
</View>
|
||||
<View style={styles.readMore}>
|
||||
<Text style={[styles.readMoreText, { color: theme.colors.primary }]}>Read More</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={theme.colors.primary} />
|
||||
<Icon name="chevron-forward" size={14} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
@@ -103,8 +103,7 @@ const styles = StyleSheet.create({
|
||||
category: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
urgentBadge: {
|
||||
paddingHorizontal: 6,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Share, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Card, ScreenShell } from '../../../components';
|
||||
import { ServicesStackParamList } from '../../../navigation/types';
|
||||
@@ -44,13 +44,13 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
|
||||
const rightContent = (
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable onPress={handleShare} style={[styles.actionBtn, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="share-social-outline" size={20} color={theme.colors.textPrimary} />
|
||||
<Icon name="share-social-outline" size={20} color={theme.colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => toggleBookmark(articleId)}
|
||||
style={[styles.actionBtn, { backgroundColor: isBookmarked ? `${theme.colors.primary}15` : theme.colors.surfaceMuted }]}
|
||||
style={[styles.actionBtn, { backgroundColor: isBookmarked ? theme.tint(theme.colors.primary, 0.08) : theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
|
||||
size={20}
|
||||
color={isBookmarked ? theme.colors.primary : theme.colors.textPrimary}
|
||||
@@ -67,7 +67,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.metaRow}>
|
||||
<View style={[styles.categoryBadge, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<View style={[styles.categoryBadge, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Text style={[styles.categoryText, { color: theme.colors.primary }]}>{article.category}</Text>
|
||||
</View>
|
||||
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>{article.publishDate}</Text>
|
||||
@@ -95,9 +95,9 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.safetyCta}>
|
||||
<View style={[styles.ctaIcon, { backgroundColor: `${theme.colors.success}15` }]}>
|
||||
<Ionicons name="shield-checkmark-outline" size={24} color={theme.colors.success} />
|
||||
<View style={[styles.safetyCta, { backgroundColor: theme.colors.successSurface }]}>
|
||||
<View style={[styles.ctaIcon, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
|
||||
<Icon name="shield-checkmark-outline" size={24} color={theme.colors.success} />
|
||||
</View>
|
||||
<View style={styles.ctaContent}>
|
||||
<Text style={[styles.ctaTitle, { color: theme.colors.textPrimary }]}>Stay Protected</Text>
|
||||
@@ -115,7 +115,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
|
||||
onPress={() => navigation.navigate('ServiceRequestsHub')}
|
||||
style={[styles.reportBtn, { borderColor: theme.colors.cardBorder }]}
|
||||
>
|
||||
<Ionicons name="megaphone-outline" size={18} color={theme.colors.error} />
|
||||
<Icon name="megaphone-outline" size={18} color={theme.colors.error} />
|
||||
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -156,8 +156,7 @@ const styles = StyleSheet.create({
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
@@ -203,7 +202,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
safetyCta: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.05)',
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
gap: 16,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { FlatList, ScrollView, StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Input, ScreenShell } from '../../../components';
|
||||
import { ServicesStackParamList } from '../../../navigation/types';
|
||||
@@ -36,7 +36,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
|
||||
placeholder="Search safety tips, fraud alerts..."
|
||||
value={filter.searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
leftAccessory={<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />}
|
||||
leftAccessory={<Icon name="search-outline" size={20} color={theme.colors.textMuted} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
|
||||
{urgentArticles.length > 0 && filter.category === 'All' && filter.searchQuery === '' && (
|
||||
<View style={styles.urgentSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="alert-circle" size={18} color={theme.colors.error} />
|
||||
<Icon name="alert-circle" size={18} color={theme.colors.error} />
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Critical Security Alerts
|
||||
</Text>
|
||||
@@ -96,10 +96,10 @@ export default function AwarenessListScreen({ navigation }: Props) {
|
||||
<Pressable
|
||||
key={article.id}
|
||||
onPress={() => navigation.navigate('AwarenessDetail', { articleId: article.id })}
|
||||
style={[styles.urgentCard, { backgroundColor: `${theme.colors.error}08`, borderColor: `${theme.colors.error}20` }]}
|
||||
style={[styles.urgentCard, { backgroundColor: theme.tint(theme.colors.error, 0.03), borderColor: theme.tint(theme.colors.error, 0.13) }]}
|
||||
>
|
||||
<View style={[styles.urgentIcon, { backgroundColor: `${theme.colors.error}15` }]}>
|
||||
<Ionicons name={article.icon as any} size={20} color={theme.colors.error} />
|
||||
<View style={[styles.urgentIcon, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
|
||||
<Icon name={article.icon as any} size={20} color={theme.colors.error} />
|
||||
</View>
|
||||
<Text style={[styles.urgentCardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{article.title}
|
||||
@@ -122,7 +122,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="search-outline" size={48} color={theme.colors.textMuted} />
|
||||
<Icon name="search-outline" size={48} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No results found</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Try adjusting your search or filters to find what you're looking for.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CardPhysicalDelivery } from '../../../types/app';
|
||||
@@ -54,10 +54,10 @@ const CardDeliveryStatusComponent = ({
|
||||
: theme.colors.primary;
|
||||
const accentBackground =
|
||||
delivery.status === 'delivered'
|
||||
? `${theme.colors.accent}12`
|
||||
? theme.tint(theme.colors.accent, 0.07)
|
||||
: delivery.status === 'ready-for-pickup'
|
||||
? `${theme.colors.warning}14`
|
||||
: `${theme.colors.primary}12`;
|
||||
? theme.tint(theme.colors.warning, 0.08)
|
||||
: theme.tint(theme.colors.primary, 0.07);
|
||||
const modeLabel = delivery.mode === 'home' ? 'Home delivery' : 'Branch pickup';
|
||||
|
||||
return (
|
||||
@@ -75,7 +75,7 @@ const CardDeliveryStatusComponent = ({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.badge, { backgroundColor: accentBackground }]}>
|
||||
<Ionicons name="cube-outline" size={14} color={accentColor} />
|
||||
<Icon name="cube-outline" size={14} color={accentColor} />
|
||||
<Text style={[styles.badgeText, { color: accentColor }]}>{modeLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -120,7 +120,7 @@ const CardDeliveryStatusComponent = ({
|
||||
<View style={styles.timelineTopRow}>
|
||||
<View style={[styles.timelineDot, timelineDotStyle]}>
|
||||
{isCompleted ? (
|
||||
<Ionicons name="checkmark" size={10} color={theme.colors.white} />
|
||||
<Icon name="checkmark" size={10} color={theme.colors.white} />
|
||||
) : null}
|
||||
</View>
|
||||
{index !== steps.length - 1 ? (
|
||||
@@ -186,8 +186,7 @@ const styles = StyleSheet.create({
|
||||
eyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
@@ -223,8 +222,7 @@ const styles = StyleSheet.create({
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
@@ -112,7 +112,7 @@ const CardItemComponent = ({
|
||||
<Text style={styles.footerValue}>{card.network}</Text>
|
||||
</View>
|
||||
<View style={styles.flipHint}>
|
||||
<Ionicons name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
|
||||
<Icon name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
|
||||
<Text style={styles.flipText}>Flip</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -216,8 +216,7 @@ const styles = StyleSheet.create({
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
cardNumber: {
|
||||
color: 'white',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutUp,
|
||||
@@ -18,7 +18,7 @@ import { useTheme, fontFamily } from '../../../theme';
|
||||
interface ExpandableSectionProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
icon: React.ComponentProps<typeof Icon>['name'];
|
||||
defaultExpanded?: boolean;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -42,10 +42,10 @@ const ExpandableSectionComponent = ({
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
|
||||
<Icon name={icon as any} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<View style={styles.titleRow}>
|
||||
@@ -69,7 +69,7 @@ const ExpandableSectionComponent = ({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={expanded ? 'chevron-up-outline' : 'chevron-down-outline'}
|
||||
size={18}
|
||||
color={theme.colors.textSecondary}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -147,11 +147,11 @@ const LimitSliderComponent = ({
|
||||
const decreaseDisabled = value <= min;
|
||||
const increaseDisabled = value >= max;
|
||||
const decreaseButtonStyle = {
|
||||
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
|
||||
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : theme.tint(theme.colors.primary, 0.07),
|
||||
opacity: decreaseDisabled ? 0.6 : 1,
|
||||
};
|
||||
const increaseButtonStyle = {
|
||||
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
|
||||
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : theme.tint(theme.colors.primary, 0.07),
|
||||
opacity: increaseDisabled ? 0.6 : 1,
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@ const LimitSliderComponent = ({
|
||||
disabled={decreaseDisabled}
|
||||
style={[styles.stepButton, decreaseButtonStyle]}
|
||||
>
|
||||
<Ionicons name="remove-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="remove-outline" size={18} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.trackColumn}>
|
||||
@@ -221,7 +221,7 @@ const LimitSliderComponent = ({
|
||||
styles.thumb,
|
||||
thumbStyle,
|
||||
{
|
||||
borderColor: `${theme.colors.primary}30`,
|
||||
borderColor: theme.tint(theme.colors.primary, 0.19),
|
||||
backgroundColor: theme.colors.white,
|
||||
},
|
||||
]}
|
||||
@@ -246,7 +246,7 @@ const LimitSliderComponent = ({
|
||||
disabled={increaseDisabled}
|
||||
style={[styles.stepButton, increaseButtonStyle]}
|
||||
>
|
||||
<Ionicons name="add-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="add-outline" size={18} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { CardTransaction } from '../../../types/app';
|
||||
@@ -33,18 +33,18 @@ const TransactionItemComponent = ({
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
showDivider && styles.rowDivider,
|
||||
showDivider && [styles.rowDivider, { borderBottomColor: theme.colors.cardBorder }],
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.iconFrame,
|
||||
{
|
||||
backgroundColor: isCredit ? `${theme.colors.success}12` : `${theme.colors.primary}12`,
|
||||
backgroundColor: isCredit ? theme.tint(theme.colors.success, 0.07) : theme.tint(theme.colors.primary, 0.07),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isCredit ? 'arrow-down-outline' : 'arrow-up-outline'}
|
||||
size={18}
|
||||
color={isCredit ? theme.colors.success : theme.colors.primary}
|
||||
@@ -78,7 +78,7 @@ const TransactionItemComponent = ({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusPill, { backgroundColor: `${statusTone}12` }]}>
|
||||
<View style={[styles.statusPill, { backgroundColor: theme.tint(statusTone, 0.07) }]}>
|
||||
<Text style={[styles.statusText, { color: statusTone }]}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
@@ -96,7 +96,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
rowDivider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
|
||||
},
|
||||
iconFrame: {
|
||||
width: 44,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
@@ -142,10 +142,10 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.warning}14` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.08) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={22} color={theme.colors.warning} />
|
||||
<Icon name="sparkles-outline" size={22} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -208,8 +208,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.productCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -223,7 +223,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={theme.colors.primary} />
|
||||
<Icon name="checkmark-circle" size={20} color={theme.colors.primary} />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.productMetaRow}>
|
||||
@@ -237,7 +237,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
<View style={styles.benefitList}>
|
||||
{product.benefits.map((benefit) => (
|
||||
<View key={benefit} style={styles.benefitItem}>
|
||||
<Ionicons name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
|
||||
<Icon name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
|
||||
<Text style={[styles.benefitText, { color: theme.colors.textSecondary }]}>
|
||||
{benefit}
|
||||
</Text>
|
||||
@@ -271,8 +271,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -331,8 +331,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.accountCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.accent, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.accent, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -346,7 +346,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
|
||||
<Icon name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
|
||||
@@ -385,7 +385,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
]}
|
||||
>
|
||||
{consentAccepted ? (
|
||||
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
|
||||
<Icon name="checkmark" size={14} color={theme.colors.white} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>
|
||||
@@ -444,7 +444,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
|
||||
<Button
|
||||
title="Submit credit card application"
|
||||
onPress={handleApply}
|
||||
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={{ height: 60 }} />
|
||||
</ScrollView>
|
||||
@@ -498,8 +498,7 @@ const styles = StyleSheet.create({
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
@@ -95,8 +95,8 @@ const FilterChip = ({ label, active, onPress }: FilterChipProps) => {
|
||||
style={[
|
||||
styles.filterChip,
|
||||
{
|
||||
backgroundColor: active ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: active ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: active ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
|
||||
borderColor: active ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -117,7 +117,7 @@ const StatementAction = ({
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
icon: React.ComponentProps<typeof Icon>['name'];
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
@@ -131,7 +131,7 @@ const StatementAction = ({
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon as any} size={14} color={theme.colors.primary} />
|
||||
<Icon name={icon as any} size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.statementActionText, { color: theme.colors.textPrimary }]}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -353,6 +353,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
scrollEnabled={!sliderInteractionActive}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
@@ -422,7 +423,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
style={styles.primaryActionButton}
|
||||
onPress={handleToggleBlock}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={card.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
|
||||
size={18}
|
||||
color={card.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white}
|
||||
@@ -438,7 +439,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
style={styles.primaryActionButton}
|
||||
onPress={handleToggleFreeze}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={card.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
@@ -620,7 +621,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
variant={sensitiveVisible ? 'secondary' : 'primary'}
|
||||
onPress={handleRevealSensitive}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={sensitiveVisible ? 'eye-off-outline' : 'finger-print-outline'}
|
||||
size={18}
|
||||
color={sensitiveVisible ? theme.colors.textPrimary : theme.colors.white}
|
||||
@@ -637,7 +638,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
{
|
||||
backgroundColor:
|
||||
account.accountNumber === card.primaryLinkedAccountId
|
||||
? `${theme.colors.primary}12`
|
||||
? theme.tint(theme.colors.primary, 0.07)
|
||||
: theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
@@ -655,7 +656,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.billingCard}>
|
||||
<View style={[styles.billingCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<View>
|
||||
<Text style={[styles.billingLabel, { color: theme.colors.textSecondary }]}>
|
||||
Billing cycle
|
||||
@@ -683,7 +684,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
>
|
||||
{card.virtualCard ? (
|
||||
<>
|
||||
<View style={styles.virtualCardTile}>
|
||||
<View style={[styles.virtualCardTile, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.virtualLabel, { color: theme.colors.textSecondary }]}>
|
||||
Virtual card number
|
||||
</Text>
|
||||
@@ -714,7 +715,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
title="Delete virtual card"
|
||||
variant="danger"
|
||||
onPress={handleDeleteVirtualCard}
|
||||
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="trash-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -738,7 +739,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
<Button
|
||||
title="Generate virtual card"
|
||||
onPress={handleGenerateVirtualCard}
|
||||
leftAccessory={<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="add-circle-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -896,7 +897,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.primaryActionButton}
|
||||
onPress={() => handlePinAction('Change PIN')}
|
||||
leftAccessory={<Ionicons name="create-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="create-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<Button
|
||||
title="Reset PIN"
|
||||
@@ -904,12 +905,12 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.primaryActionButton}
|
||||
onPress={() => handlePinAction('Reset PIN')}
|
||||
leftAccessory={<Ionicons name="refresh-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="refresh-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.securityNote, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="finger-print-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="finger-print-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.securityNoteText, { color: theme.colors.textSecondary }]}>
|
||||
Biometric authentication is required before viewing CVV, changing PIN, or resetting PIN.
|
||||
</Text>
|
||||
@@ -938,7 +939,7 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
title="Redeem"
|
||||
fullWidth={false}
|
||||
onPress={handleRedeemRewards}
|
||||
leftAccessory={<Ionicons name="cash-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="cash-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -983,18 +984,18 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
title="Save card name"
|
||||
variant="secondary"
|
||||
onPress={handleRenameCard}
|
||||
leftAccessory={<Ionicons name="save-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="save-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
|
||||
{!card.isDefault ? (
|
||||
<Button
|
||||
title="Set as default card"
|
||||
onPress={handleSetDefaultCard}
|
||||
leftAccessory={<Ionicons name="star-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="star-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.defaultCardBanner, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="star" size={16} color={theme.colors.primary} />
|
||||
<View style={[styles.defaultCardBanner, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="star" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.defaultCardText, { color: theme.colors.primary }]}>
|
||||
This is your default card.
|
||||
</Text>
|
||||
@@ -1018,11 +1019,11 @@ export default function CardDetailsScreen({ route }: Props) {
|
||||
{
|
||||
backgroundColor:
|
||||
account.accountNumber === card.primaryLinkedAccountId
|
||||
? `${theme.colors.primary}12`
|
||||
? theme.tint(theme.colors.primary, 0.07)
|
||||
: theme.colors.surfaceMuted,
|
||||
borderColor:
|
||||
account.accountNumber === card.primaryLinkedAccountId
|
||||
? `${theme.colors.primary}35`
|
||||
? theme.tint(theme.colors.primary, 0.21)
|
||||
: theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
@@ -1078,8 +1079,7 @@ const styles = StyleSheet.create({
|
||||
heroMetricLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 21,
|
||||
@@ -1100,8 +1100,7 @@ const styles = StyleSheet.create({
|
||||
filterLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
filterWrap: {
|
||||
flexDirection: 'row',
|
||||
@@ -1131,8 +1130,7 @@ const styles = StyleSheet.create({
|
||||
secureLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
secureValue: {
|
||||
fontSize: 16,
|
||||
@@ -1150,8 +1148,7 @@ const styles = StyleSheet.create({
|
||||
linkedAccountLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
linkedAccountValue: {
|
||||
fontSize: 14,
|
||||
@@ -1163,13 +1160,11 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
billingLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
billingValue: {
|
||||
marginTop: 6,
|
||||
@@ -1181,13 +1176,11 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
virtualLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
virtualValue: {
|
||||
fontSize: 18,
|
||||
@@ -1330,8 +1323,7 @@ const styles = StyleSheet.create({
|
||||
rewardEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
rewardPoints: {
|
||||
fontSize: 30,
|
||||
@@ -1355,8 +1347,7 @@ const styles = StyleSheet.create({
|
||||
offerMerchant: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
offerTitle: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
|
||||
@@ -65,10 +65,10 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceIconWrap,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="card-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="card-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.serviceCopy}>
|
||||
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
@@ -86,7 +86,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
|
||||
@@ -96,7 +96,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>Visa</Text>
|
||||
@@ -104,7 +104,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
|
||||
@@ -117,7 +117,7 @@ const CardServiceHub = ({
|
||||
title="Request debit card"
|
||||
variant="secondary"
|
||||
onPress={onRequestDebitCard}
|
||||
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</FeatureGate>
|
||||
</View>
|
||||
@@ -125,17 +125,17 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.servicePanel,
|
||||
{ backgroundColor: `${theme.colors.primary}08` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.03) },
|
||||
]}
|
||||
>
|
||||
<View style={styles.servicePanelHeader}>
|
||||
<View
|
||||
style={[
|
||||
styles.serviceIconWrap,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={18} color={theme.colors.warning} />
|
||||
<Icon name="sparkles-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.serviceCopy}>
|
||||
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
|
||||
@@ -153,7 +153,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>
|
||||
@@ -163,7 +163,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
|
||||
@@ -173,7 +173,7 @@ const CardServiceHub = ({
|
||||
<View
|
||||
style={[
|
||||
styles.serviceTag,
|
||||
{ backgroundColor: `${theme.colors.warning}16` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
|
||||
@@ -185,7 +185,7 @@ const CardServiceHub = ({
|
||||
<Button
|
||||
title="Apply credit card"
|
||||
onPress={onApplyCreditCard}
|
||||
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="sparkles-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</FeatureGate>
|
||||
</View>
|
||||
@@ -311,7 +311,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
onPress={handleRequestDebitCard}
|
||||
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
|
||||
>
|
||||
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
|
||||
<Icon name="add-outline" size={24} color={theme.colors.white} />
|
||||
<Text style={styles.fabLabel}>Request Card</Text>
|
||||
</Pressable>
|
||||
}
|
||||
@@ -328,10 +328,10 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroBadge,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={14} color={theme.colors.primary} />
|
||||
<Icon name="sparkles-outline" size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.heroBadgeText, { color: theme.colors.primary }]}>
|
||||
{selectedCard.isDefault ? 'Default card' : 'Secondary card'}
|
||||
</Text>
|
||||
@@ -413,7 +413,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}
|
||||
leftAccessory={
|
||||
<Ionicons name="card-outline" size={18} color={theme.colors.white} />
|
||||
<Icon name="card-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
@@ -423,7 +423,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={handleToggleFreeze}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={selectedCard.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
@@ -437,7 +437,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={handleToggleBlock}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={selectedCard.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
|
||||
size={18}
|
||||
color={
|
||||
@@ -595,8 +595,7 @@ const styles = StyleSheet.create({
|
||||
heroEyebrow: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
@@ -634,8 +633,7 @@ const styles = StyleSheet.create({
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 22,
|
||||
@@ -699,8 +697,7 @@ const styles = StyleSheet.create({
|
||||
serviceEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
serviceTitle: {
|
||||
fontSize: 16,
|
||||
@@ -788,8 +785,7 @@ const styles = StyleSheet.create({
|
||||
offerMerchant: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
offerTitle: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
@@ -27,7 +27,7 @@ const NETWORK_OPTIONS: Array<{
|
||||
id: CardNetwork;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
icon: React.ComponentProps<typeof Icon>['name'];
|
||||
}> = [
|
||||
{
|
||||
id: 'RuPay',
|
||||
@@ -152,10 +152,10 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="card-outline" size={22} color={theme.colors.primary} />
|
||||
<Icon name="card-outline" size={22} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -218,8 +218,8 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -233,7 +233,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
@@ -270,20 +270,20 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.accent, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.accent, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceHeader}>
|
||||
<View style={styles.networkTitleRow}>
|
||||
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Icon name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
|
||||
{option.title}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.accentStrong}
|
||||
@@ -320,20 +320,20 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.choiceCard,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceHeader}>
|
||||
<View style={styles.networkTitleRow}>
|
||||
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Icon name={option.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
|
||||
{option.title}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="checkmark-circle"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
@@ -376,7 +376,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
{ backgroundColor: theme.colors.surfaceMuted },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="business-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="business-outline" size={18} color={theme.colors.primary} />
|
||||
<View style={styles.pickupCopy}>
|
||||
<Text style={[styles.pickupTitle, { color: theme.colors.textPrimary }]}>
|
||||
Pickup branch
|
||||
@@ -454,7 +454,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
|
||||
<Button
|
||||
title="Request physical debit card"
|
||||
onPress={handleSubmit}
|
||||
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={styles.bottomInset} />
|
||||
</ScrollView>
|
||||
@@ -508,8 +508,7 @@ const styles = StyleSheet.create({
|
||||
heroStatLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroStatValue: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
@@ -69,10 +69,10 @@ const DepositAccountCardComponent = ({
|
||||
<View
|
||||
style={[
|
||||
styles.ratePill,
|
||||
{ backgroundColor: `${theme.colors.accent}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
|
||||
<Icon name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
|
||||
<Text style={[styles.rateText, { color: theme.colors.accentStrong }]}>
|
||||
{deposit.interestRate}% p.a.
|
||||
</Text>
|
||||
@@ -153,8 +153,7 @@ const styles = StyleSheet.create({
|
||||
eyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
number: {
|
||||
fontSize: 16,
|
||||
@@ -202,8 +201,7 @@ const styles = StyleSheet.create({
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
|
||||
@@ -22,8 +22,8 @@ const DepositOptionChipComponent = ({
|
||||
style={({ pressed }) => [
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
|
||||
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
transform: [{ scale: pressed ? 0.98 : 1 }],
|
||||
},
|
||||
|
||||
@@ -19,8 +19,8 @@ const DepositRatePillComponent = ({ rate }: DepositRatePillProps) => {
|
||||
style={[
|
||||
styles.pill,
|
||||
{
|
||||
backgroundColor: 'rgba(53, 214, 163, 0.12)',
|
||||
borderColor: theme.colors.cardBorder,
|
||||
backgroundColor: theme.colors.accentSurface,
|
||||
borderColor: theme.colors.accentBorder,
|
||||
borderRadius: theme.radius.pill,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -17,24 +17,24 @@ const DepositStatusPillComponent = ({
|
||||
|
||||
const tone = nearMaturity
|
||||
? {
|
||||
backgroundColor: `${theme.colors.warning}16`,
|
||||
backgroundColor: theme.tint(theme.colors.warning, 0.09),
|
||||
color: theme.colors.warning,
|
||||
label: 'MATURING',
|
||||
}
|
||||
: status === 'closed'
|
||||
? {
|
||||
backgroundColor: `${theme.colors.textMuted}18`,
|
||||
backgroundColor: theme.tint(theme.colors.textMuted, 0.09),
|
||||
color: theme.colors.textSecondary,
|
||||
label: 'CLOSED',
|
||||
}
|
||||
: status === 'matured'
|
||||
? {
|
||||
backgroundColor: `${theme.colors.accent}16`,
|
||||
backgroundColor: theme.tint(theme.colors.accent, 0.09),
|
||||
color: theme.colors.accentStrong,
|
||||
label: 'MATURED',
|
||||
}
|
||||
: {
|
||||
backgroundColor: `${theme.colors.primary}12`,
|
||||
backgroundColor: theme.tint(theme.colors.primary, 0.07),
|
||||
color: theme.colors.primary,
|
||||
label: 'ACTIVE',
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Card, Input, ScreenShell } from '../../../components';
|
||||
@@ -171,8 +171,8 @@ export default function DepositCalculatorScreen({ route }: Props) {
|
||||
|
||||
<Card style={styles.resultCard}>
|
||||
<View style={styles.resultHeader}>
|
||||
<View style={[styles.resultIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="calculator-outline" size={20} color={theme.colors.primary} />
|
||||
<View style={[styles.resultIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="calculator-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.resultCopy}>
|
||||
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -229,8 +229,7 @@ const styles = StyleSheet.create({
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -283,8 +282,7 @@ const styles = StyleSheet.create({
|
||||
resultLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
resultTileValue: {
|
||||
fontSize: 15,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
@@ -375,7 +375,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
|
||||
onPress={() =>
|
||||
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
|
||||
}
|
||||
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</FeatureGate>
|
||||
|
||||
@@ -386,7 +386,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
|
||||
loading={closeState.isLoading}
|
||||
onPress={() => setConfirmVisible(true)}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={deposit.status === 'matured' ? 'cash-outline' : 'close-circle-outline'}
|
||||
size={18}
|
||||
color={theme.colors.white}
|
||||
@@ -476,8 +476,7 @@ const styles = StyleSheet.create({
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
@@ -498,8 +497,7 @@ const styles = StyleSheet.create({
|
||||
heroMetricLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 16,
|
||||
@@ -539,8 +537,7 @@ const styles = StyleSheet.create({
|
||||
detailLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 14,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
@@ -121,10 +121,10 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
|
||||
<Icon name="wallet-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.primary }]}>
|
||||
@@ -177,7 +177,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroBadge,
|
||||
{ backgroundColor: `${theme.colors.warning}14` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.08) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroBadgeValue, { color: theme.colors.warning }]}>
|
||||
@@ -198,7 +198,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroMetricCard,
|
||||
{ backgroundColor: `${theme.colors.primary}08` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.03) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
|
||||
@@ -254,7 +254,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenFixedDeposit}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="trending-up-outline"
|
||||
size={18}
|
||||
color={theme.colors.white}
|
||||
@@ -271,7 +271,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenRecurringDeposit}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="repeat-outline"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
@@ -288,7 +288,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
style={styles.actionButton}
|
||||
onPress={handleOpenCalculator}
|
||||
leftAccessory={
|
||||
<Ionicons
|
||||
<Icon
|
||||
name="calculator-outline"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
@@ -303,8 +303,8 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
{summary.nearMaturityDeposits.length ? (
|
||||
<Card style={styles.watchCard}>
|
||||
<View style={styles.watchHeader}>
|
||||
<View style={[styles.watchIcon, { backgroundColor: `${theme.colors.warning}16` }]}>
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.warning} />
|
||||
<View style={[styles.watchIcon, { backgroundColor: theme.tint(theme.colors.warning, 0.09) }]}>
|
||||
<Icon name="time-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.watchCopy}>
|
||||
<Text style={[styles.watchTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -334,7 +334,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
|
||||
styles.watchDays,
|
||||
{
|
||||
color: theme.colors.warning,
|
||||
backgroundColor: `${theme.colors.warning}12`,
|
||||
backgroundColor: theme.tint(theme.colors.warning, 0.07),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -462,8 +462,7 @@ const styles = StyleSheet.create({
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
@@ -489,8 +488,7 @@ const styles = StyleSheet.create({
|
||||
heroHighlightLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroHighlightValue: {
|
||||
fontSize: 28,
|
||||
@@ -519,8 +517,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroMetrics: {
|
||||
flexDirection: 'row',
|
||||
@@ -537,8 +534,7 @@ const styles = StyleSheet.create({
|
||||
heroMetricLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroMetricValue: {
|
||||
fontSize: 20,
|
||||
@@ -660,8 +656,7 @@ const styles = StyleSheet.create({
|
||||
portfolioCountLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
filterWrap: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
@@ -153,8 +153,8 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
|
||||
<Ionicons
|
||||
<View style={[styles.heroPill, { backgroundColor: theme.tint(theme.colors.accent, 0.07) }]}>
|
||||
<Icon
|
||||
name="shield-checkmark-outline"
|
||||
size={16}
|
||||
color={theme.colors.accentStrong}
|
||||
@@ -266,7 +266,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
<Icon name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
@@ -294,8 +294,7 @@ const styles = StyleSheet.create({
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
@@ -329,8 +328,7 @@ const styles = StyleSheet.create({
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -365,8 +363,7 @@ const styles = StyleSheet.create({
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
@@ -141,8 +141,8 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
{product.rate}% p.a.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="repeat-outline" size={16} color={theme.colors.primary} />
|
||||
<View style={[styles.heroPill, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="repeat-outline" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
|
||||
Min {formatCurrency(product.minimumInstallment ?? 0)}
|
||||
</Text>
|
||||
@@ -229,7 +229,7 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
|
||||
onPress={handleSubmit}
|
||||
loading={createState.isLoading}
|
||||
leftAccessory={
|
||||
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
<Icon name="add-circle-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
@@ -257,8 +257,7 @@ const styles = StyleSheet.create({
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
@@ -292,8 +291,7 @@ const styles = StyleSheet.create({
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -324,8 +322,7 @@ const styles = StyleSheet.create({
|
||||
previewLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.7,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
previewValue: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface HistoryFilters {
|
||||
|
||||
/**
|
||||
* The transfer history, accumulated page by page — the same shape as
|
||||
* `useStatement`, for the same reason: RTK Query caches one entry per page, and
|
||||
* the old paged statement hook, for the same reason: RTK Query caches one entry per page, and
|
||||
* the list needs every page walked so far.
|
||||
*
|
||||
* Any change of filter starts again from page 0, as the guide requires; page 2
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
@@ -98,7 +98,7 @@ export default function TransferDetailScreen({ navigation, route }: Props) {
|
||||
{/* ── the outcome ───────────────────────────────────────────────── */}
|
||||
<Card style={styles.hero}>
|
||||
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={status.icon} size={30} color={colors.tone} />
|
||||
<Icon name={status.icon} size={30} color={colors.tone} />
|
||||
</View>
|
||||
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(data.amount)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import DatePicker from 'react-native-date-picker';
|
||||
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { toStatementDate } from '../../home/hooks/useStatement';
|
||||
import { toStatementDate } from '../../home/utils/statement';
|
||||
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
|
||||
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
@@ -72,7 +72,7 @@ function TransferRow({
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={copy.icon} size={20} color={colors.tone} />
|
||||
<Icon name={copy.icon} size={20} color={colors.tone} />
|
||||
</View>
|
||||
|
||||
<View style={styles.copy}>
|
||||
@@ -228,13 +228,13 @@ export default function TransferHistoryScreen({ navigation }: Props) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
|
||||
{hasRange
|
||||
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
|
||||
: 'Any date'}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
<Icon name="chevron-down" size={16} color={theme.colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -316,7 +316,7 @@ export default function TransferHistoryScreen({ navigation }: Props) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import type { Icon } from '../../../components/Icon';
|
||||
import type React from 'react';
|
||||
|
||||
import type { FundTransferStatus } from '../../../types/cbs';
|
||||
import type { useTheme } from '../../../theme';
|
||||
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IconName = React.ComponentProps<typeof Icon>['name'];
|
||||
type Colors = ReturnType<typeof useTheme>['colors'];
|
||||
|
||||
export type StatusTone = 'success' | 'danger' | 'warning' | 'info';
|
||||
|
||||
@@ -14,8 +14,8 @@ export const HomeSectionHeader = ({ title, onSeeAll }: HomeSectionHeaderProps) =
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{onSeeAll && (
|
||||
<Pressable onPress={onSeeAll}>
|
||||
<Text style={[styles.seeAll, { color: theme.colors.primary }]}>See All</Text>
|
||||
<Pressable onPress={onSeeAll} hitSlop={8} accessibilityRole="button">
|
||||
<Text style={[styles.seeAll, { color: theme.colors.primary }]}>See all</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -27,16 +27,16 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontSize: 17,
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: -0.5,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
seeAll: {
|
||||
fontSize: 14,
|
||||
fontFamily: fontFamily.bold,
|
||||
fontSize: 13,
|
||||
fontFamily: fontFamily.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, ScrollView, Pressable } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useEnabledItems } from '../../../features';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
@@ -26,6 +26,7 @@ const CATEGORIES: Category[] = [
|
||||
{ id: 'deposits', name: 'Deposits', icon: 'wallet-outline', feature: FeatureKey.SERVICES_DEPOSITS_OVERVIEW },
|
||||
{ id: 'loans', name: 'Loans', icon: 'business-outline', feature: FeatureKey.LOANS },
|
||||
{ id: 'insurance', name: 'Insurance', icon: 'shield-outline', feature: FeatureKey.INSURANCE },
|
||||
{ id: 'mmid', name: 'MMID', icon: 'qr-code-outline', feature: FeatureKey.HOME_ACCOUNTS_MMID },
|
||||
];
|
||||
|
||||
export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesProps) => {
|
||||
@@ -38,91 +39,71 @@ export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesPr
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* A row of icon tiles: a tinted rounded square with the glyph in brand blue
|
||||
* and the label underneath. Four to a row, equal width, so the grid lines up
|
||||
* with the account actions above it rather than scrolling off the edge.
|
||||
*/
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
{categories.map((cat) => {
|
||||
return (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() => onCategoryPress?.(cat.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.pill,
|
||||
{
|
||||
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: pressed ? 0.08 : 0.05,
|
||||
shadowRadius: pressed ? 10 : 8,
|
||||
elevation: pressed ? 4 : 2,
|
||||
opacity: pressed ? 0.96 : 1,
|
||||
transform: [{ scale: pressed ? 0.97 : 1 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.iconBox,
|
||||
{
|
||||
backgroundColor: pressed
|
||||
? 'rgba(0, 0, 0, 0.08)'
|
||||
: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={cat.icon as any}
|
||||
size={18}
|
||||
color={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.name,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.grid}>
|
||||
{categories.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={cat.name}
|
||||
onPress={() => onCategoryPress?.(cat.id)}
|
||||
style={styles.tile}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.iconBox,
|
||||
{
|
||||
borderRadius: theme.radius.lg,
|
||||
backgroundColor: pressed
|
||||
? theme.colors.primaryBorder
|
||||
: theme.colors.primarySurface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Icon
|
||||
name={cat.icon as React.ComponentProps<typeof Icon>['name']}
|
||||
size={24}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text numberOfLines={1} style={[styles.name, { color: theme.colors.textPrimary }]}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 6,
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
pill: {
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
rowGap: 16,
|
||||
},
|
||||
tile: {
|
||||
width: '25%',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
},
|
||||
iconBox: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 14,
|
||||
fontFamily: fontFamily.bold,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontFamily: fontFamily.medium,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily, type CategoryHue } from '../../../theme';
|
||||
import { Card } from '../../../components';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
@@ -66,7 +66,7 @@ const QuickActionsComponent = ({ onPressRoute }: QuickActionsProps) => {
|
||||
{ backgroundColor: theme.colors.category[action.hue].surface },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={action.icon as any}
|
||||
size={22}
|
||||
color={theme.colors.category[action.hue].tone}
|
||||
@@ -116,12 +116,11 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 9,
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
fontFamily: fontFamily.semibold,
|
||||
textAlign: 'center',
|
||||
opacity: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
interface ServiceGridItemProps {
|
||||
@@ -45,12 +45,12 @@ const ServiceGridItemComponent = ({
|
||||
style={[
|
||||
styles.iconFrame,
|
||||
{
|
||||
backgroundColor: `${iconColor || theme.colors.primary}12`,
|
||||
borderColor: `${iconColor || theme.colors.primary}18`,
|
||||
backgroundColor: theme.tint(iconColor || theme.colors.primary, 0.07),
|
||||
borderColor: theme.tint(iconColor || theme.colors.primary, 0.09),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon as any} size={22} color={iconColor || theme.colors.primary} />
|
||||
<Icon name={icon as any} size={22} color={iconColor || theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={2}>
|
||||
{title}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { Card } from '../../../components';
|
||||
|
||||
@@ -21,8 +21,8 @@ const SummaryCardComponent = ({ branch, user, tenant }: SummaryCardProps) => {
|
||||
return (
|
||||
<Card style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
|
||||
<Ionicons name="information-circle" size={18} color={theme.colors.primary} />
|
||||
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
|
||||
<Icon name="information-circle" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Account Details</Text>
|
||||
@@ -93,10 +93,9 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useGetMiniStatementQuery } from '../services/accountsApi';
|
||||
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
|
||||
|
||||
const NO_ROWS: CbsMiniStatementTransaction[] = [];
|
||||
|
||||
/**
|
||||
* The latest (up to ten) entries on one account, newest first.
|
||||
*
|
||||
* Refetched whenever the account changes or a screen mounts, so what is shown
|
||||
* was fetched for that screen. Reads `currentData` rather than `data`: RTK
|
||||
* Query keeps the *previous* account's result in `data` while the new one
|
||||
* loads, and the guide asks that the prior account's view be cleared the
|
||||
* moment the selection changes — one account's entries must never sit under
|
||||
* another account's name, even for a frame.
|
||||
*/
|
||||
export const useMiniStatement = (accountNumber?: string, options: { skip?: boolean } = {}) => {
|
||||
const { currentData, isFetching, error, refetch } = useGetMiniStatementQuery(
|
||||
accountNumber ?? '',
|
||||
{ skip: !accountNumber || options.skip, refetchOnMountOrArgChange: true },
|
||||
);
|
||||
|
||||
return {
|
||||
transactions: currentData?.transactions ?? NO_ROWS,
|
||||
accountNumberMasked: currentData?.accountNumberMasked ?? null,
|
||||
// `currentData` is undefined while a request for this account is in
|
||||
// flight, which is exactly when a spinner belongs on screen.
|
||||
isLoading: isFetching && !currentData,
|
||||
isFetching,
|
||||
// An error is only meaningful once nothing for this account has loaded.
|
||||
error: currentData ? undefined : error,
|
||||
reload: refetch,
|
||||
};
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useGetStatementQuery } from '../services/accountsApi';
|
||||
import type { CbsStatementTransaction } from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Rows per request. Below the bank's cap of 100, and high enough that the
|
||||
* common case — a customer glancing at a month — is one round trip.
|
||||
*/
|
||||
export const STATEMENT_PAGE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* A date in the `yyyy-MM-dd` the statement endpoint wants, read off the local
|
||||
* calendar.
|
||||
*
|
||||
* Deliberately not `toISOString().slice(0, 10)`. That converts to UTC first,
|
||||
* so for anyone east of Greenwich — every customer of this bank, at +05:30 —
|
||||
* a date picked as the 17th is sent as the 16th for most of the day, and a
|
||||
* range quietly loses its first day and gains a day at the other end.
|
||||
*/
|
||||
export const toStatementDate = (date: Date) => {
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
};
|
||||
|
||||
interface UseStatementArgs {
|
||||
accountNumber?: string;
|
||||
/** Sent only as a pair — see `CbsStatementQuery`. */
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An account's ledger, accumulated page by page.
|
||||
*
|
||||
* RTK Query holds one cache entry per page, which is what makes paging back
|
||||
* free, but a list needs every page it has walked through. The pages are kept
|
||||
* here keyed by the page number the *response* reports rather than by the one
|
||||
* requested, so a bank that clamps or re-numbers a page cannot leave a row
|
||||
* filed under the wrong index.
|
||||
*
|
||||
* The whole accumulation is thrown away when the account or the date range
|
||||
* changes: those are different ledgers, and page 2 of the old one has no
|
||||
* meaning in the new one.
|
||||
*/
|
||||
export const useStatement = ({ accountNumber, fromDate, toDate }: UseStatementArgs) => {
|
||||
const queryKey = `${accountNumber ?? ''}|${fromDate ?? ''}|${toDate ?? ''}`;
|
||||
const [page, setPage] = useState(0);
|
||||
const [pages, setPages] = useState<Record<number, CbsStatementTransaction[]>>({});
|
||||
const loadedKey = useRef(queryKey);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetStatementQuery(
|
||||
{
|
||||
accountNumber: accountNumber ?? '',
|
||||
fromDate,
|
||||
toDate,
|
||||
page,
|
||||
size: STATEMENT_PAGE_SIZE,
|
||||
},
|
||||
{ skip: !accountNumber, refetchOnMountOrArgChange: true },
|
||||
);
|
||||
|
||||
// Reset before the new key's first response lands, not after, so the list is
|
||||
// never briefly the old account's rows under the new account's header.
|
||||
if (loadedKey.current !== queryKey) {
|
||||
loadedKey.current = queryKey;
|
||||
|
||||
if (page !== 0) {
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
if (Object.keys(pages).length > 0) {
|
||||
setPages({});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPages((current) =>
|
||||
current[data.page] === data.content
|
||||
? current
|
||||
: { ...current, [data.page]: data.content },
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
/**
|
||||
* Flattened in page order, with repeats dropped.
|
||||
*
|
||||
* The endpoint warns that a page can repeat a row when its server-side cache
|
||||
* expires mid-browse, and `referenceNumber` is the only stable key these
|
||||
* rows carry — so the same entry arriving twice becomes one row here rather
|
||||
* than a duplicate in the list and a duplicate in the totals.
|
||||
*/
|
||||
const transactions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const ordered: CbsStatementTransaction[] = [];
|
||||
|
||||
Object.keys(pages)
|
||||
.map(Number)
|
||||
.sort((left, right) => left - right)
|
||||
.forEach((index) => {
|
||||
(pages[index] ?? []).forEach((item) => {
|
||||
if (seen.has(item.referenceNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(item.referenceNumber);
|
||||
ordered.push(item);
|
||||
});
|
||||
});
|
||||
|
||||
return ordered;
|
||||
}, [pages]);
|
||||
|
||||
const hasMore = Boolean(data?.hasMore);
|
||||
// True only while another page is on its way, never while the first one is.
|
||||
const isLoadingMore = isFetching && page > 0;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPage((current) => current + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
/**
|
||||
* Start again from page 0.
|
||||
*
|
||||
* On page 0 the fresh rows are written from the refetch's own result rather
|
||||
* than left to the `[data]` effect: an identical response comes back as the
|
||||
* SAME `data` object under RTK Query's structural sharing, the effect never
|
||||
* fires, and a list cleared first would stay empty. Off page 0, moving back
|
||||
* to it is a different cache entry, so the effect refills the list — and
|
||||
* refetching here would only re-request the page being left.
|
||||
*/
|
||||
const reload = useCallback(() => {
|
||||
if (page !== 0) {
|
||||
setPages({});
|
||||
setPage(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
refetch()
|
||||
.unwrap()
|
||||
.then((fresh) => setPages({ [fresh.page]: fresh.content }))
|
||||
.catch(() => {
|
||||
// Surfaced through `error`; whatever is on screen is kept.
|
||||
});
|
||||
}, [page, refetch]);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
totalElements: data?.totalElements ?? 0,
|
||||
hasMore,
|
||||
// The first page still being fetched, with nothing to show behind it.
|
||||
isLoading: isLoading || (isFetching && transactions.length === 0),
|
||||
isLoadingMore,
|
||||
isFetching,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { AccountCard, Button, Card, ScreenShell } from '../../../components';
|
||||
@@ -27,7 +27,7 @@ const SPACING = 16;
|
||||
const CARD_WIDTH = SCREEN_WIDTH - 32;
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'AccountDetails'>;
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IconName = React.ComponentProps<typeof Icon>['name'];
|
||||
|
||||
type DetailItem = {
|
||||
id: string;
|
||||
@@ -128,10 +128,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.emptyIcon,
|
||||
{ backgroundColor: `${theme.colors.danger}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.danger, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="alert-circle-outline" size={28} color={theme.colors.danger} />
|
||||
<Icon name="alert-circle-outline" size={28} color={theme.colors.danger} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
|
||||
Account not found
|
||||
@@ -331,7 +331,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: `${theme.colors.success}14` },
|
||||
{ backgroundColor: theme.tint(theme.colors.success, 0.08) },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
@@ -369,10 +369,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.sectionIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="document-text-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="document-text-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -399,7 +399,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
{ backgroundColor: theme.colors.surface },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={item.icon as any} size={18} color={theme.colors.primary} />
|
||||
<Icon name={item.icon as any} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
|
||||
{item.label}
|
||||
@@ -417,10 +417,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.reKycIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="shield-checkmark-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.reKycCopy}>
|
||||
<Text style={[styles.reKycTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -436,7 +436,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
title="Re-KYC via Aadhaar"
|
||||
onPress={() => navigation.navigate('ReKyc', { accountNumber: account.accountNumber })}
|
||||
leftAccessory={
|
||||
<Ionicons name="finger-print-outline" size={18} color={theme.colors.white} />
|
||||
<Icon name="finger-print-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
@@ -446,10 +446,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.sectionIcon,
|
||||
{ backgroundColor: `${theme.colors.info}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.info, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="person-circle-outline" size={18} color={theme.colors.info} />
|
||||
<Icon name="person-circle-outline" size={18} color={theme.colors.info} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -467,17 +467,20 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.profileRow,
|
||||
index !== profileItems.length - 1 && styles.profileRowDivider,
|
||||
index !== profileItems.length - 1 && [
|
||||
styles.profileRowDivider,
|
||||
{ borderBottomColor: theme.colors.cardBorder },
|
||||
],
|
||||
]}
|
||||
>
|
||||
<View style={styles.profileLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.profileIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}10` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.06) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={item.icon as any} size={16} color={theme.colors.primary} />
|
||||
<Icon name={item.icon as any} size={16} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.profileCopy}>
|
||||
<Text style={[styles.profileLabel, { color: theme.colors.textSecondary }]}>
|
||||
@@ -498,10 +501,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.sectionIcon,
|
||||
{ backgroundColor: `${theme.colors.warning}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="wallet-outline" size={18} color={theme.colors.warning} />
|
||||
<Icon name="wallet-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -540,7 +543,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.actionButton}
|
||||
leftAccessory={
|
||||
<Ionicons name="download-outline" size={18} color={theme.colors.white} />
|
||||
<Icon name="download-outline" size={18} color={theme.colors.white} />
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
@@ -550,7 +553,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.actionButton}
|
||||
leftAccessory={
|
||||
<Ionicons name="book-outline" size={18} color={theme.colors.textPrimary} />
|
||||
<Icon name="book-outline" size={18} color={theme.colors.textPrimary} />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -595,8 +598,7 @@ const styles = StyleSheet.create({
|
||||
highlightLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
highlightTitle: {
|
||||
fontSize: 22,
|
||||
@@ -719,8 +721,7 @@ const styles = StyleSheet.create({
|
||||
detailLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 15,
|
||||
@@ -735,7 +736,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
profileRowDivider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
|
||||
},
|
||||
profileLeft: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
@@ -78,7 +78,7 @@ export default function AccountsMmidScreen(_props: Props) {
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.statusPill, { backgroundColor: `${toneColor(account.status)}1A` }]}
|
||||
style={[styles.statusPill, { backgroundColor: theme.tint(toneColor(account.status), 0.1) }]}
|
||||
>
|
||||
<Text style={[styles.statusText, { color: toneColor(account.status) }]}>
|
||||
{titleCase(account.status)}
|
||||
@@ -137,11 +137,11 @@ export default function AccountsMmidScreen(_props: Props) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={[styles.mobileCard, { backgroundColor: `${theme.colors.primary}0D` }]}>
|
||||
<Ionicons name="call-outline" size={18} color={theme.colors.primary} />
|
||||
<View style={[styles.mobileCard, { backgroundColor: theme.tint(theme.colors.primary, 0.05) }]}>
|
||||
<Icon name="call-outline" size={18} color={theme.colors.primary} />
|
||||
<View style={styles.mobileCopy}>
|
||||
<Text style={[styles.mobileLabel, { color: theme.colors.textSecondary }]}>
|
||||
REGISTERED MOBILE
|
||||
Registered mobile
|
||||
</Text>
|
||||
<Text style={[styles.mobileValue, { color: theme.colors.textPrimary }]}>
|
||||
{mobileNumber ?? '—'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
@@ -66,7 +66,7 @@ export default function EPassbookScreen({ route }: Props) {
|
||||
return (
|
||||
<ScreenShell title="E-Passbook" subtitle="Account ledger">
|
||||
<Card style={styles.emptyCard}>
|
||||
<Ionicons name="book-outline" size={28} color={theme.colors.textMuted} />
|
||||
<Icon name="book-outline" size={28} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No account found</Text>
|
||||
</Card>
|
||||
</ScreenShell>
|
||||
@@ -87,8 +87,8 @@ export default function EPassbookScreen({ route }: Props) {
|
||||
{/* Passbook cover */}
|
||||
<Card style={styles.coverCard}>
|
||||
<View style={styles.coverHeader}>
|
||||
<View style={[styles.coverIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="book-outline" size={20} color={theme.colors.primary} />
|
||||
<View style={[styles.coverIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="book-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.coverName, { color: theme.colors.textPrimary }]}>{customerName || "Account holder"}</Text>
|
||||
@@ -155,7 +155,7 @@ export default function EPassbookScreen({ route }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.actionBtn}
|
||||
onPress={() => toast('Download started', 'success', 'Your e-passbook is being saved to your device.')}
|
||||
leftAccessory={<Ionicons name="cloud-download-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="cloud-download-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<Button
|
||||
title="Share"
|
||||
@@ -163,7 +163,7 @@ export default function EPassbookScreen({ route }: Props) {
|
||||
fullWidth={false}
|
||||
style={styles.actionBtn}
|
||||
onPress={() => toast('Share sheet', 'info', 'Choose an app to share your e-passbook.')}
|
||||
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ height: 40 }} />
|
||||
@@ -175,7 +175,7 @@ const styles = StyleSheet.create({
|
||||
content: { gap: 16 },
|
||||
selector: { gap: 10, paddingRight: 12 },
|
||||
accountChip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1.5, gap: 2, minWidth: 120 },
|
||||
chipType: { fontSize: 12, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
|
||||
chipType: { fontSize: 12, fontFamily: fontFamily.bold },
|
||||
chipMask: { fontSize: 12, fontFamily: fontFamily.medium },
|
||||
coverCard: { padding: 18, gap: 16 },
|
||||
coverHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
@@ -184,13 +184,13 @@ const styles = StyleSheet.create({
|
||||
coverMeta: { fontSize: 12, fontFamily: fontFamily.medium },
|
||||
coverGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
|
||||
coverItem: { width: '47%', gap: 4 },
|
||||
coverLabel: { fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', letterSpacing: 0.6 },
|
||||
coverLabel: { fontSize: 11, fontFamily: fontFamily.bold, letterSpacing: 0.1 },
|
||||
coverValue: { fontSize: 14, fontFamily: fontFamily.bold },
|
||||
ledgerCard: { padding: 16 },
|
||||
ledgerHead: { flexDirection: 'row', paddingBottom: 10, borderBottomWidth: 1 },
|
||||
thDate: { width: 64, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
|
||||
thParticulars: { flex: 1, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', paddingHorizontal: 8 },
|
||||
thAmount: { width: 84, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', textAlign: 'right' },
|
||||
thDate: { width: 64, fontSize: 11, fontFamily: fontFamily.bold },
|
||||
thParticulars: { flex: 1, fontSize: 11, fontFamily: fontFamily.bold, paddingHorizontal: 8 },
|
||||
thAmount: { width: 84, fontSize: 11, fontFamily: fontFamily.bold, textAlign: 'right' },
|
||||
ledgerRow: { flexDirection: 'row', paddingVertical: 12, borderBottomWidth: 1, alignItems: 'center' },
|
||||
tdDate: { width: 64, fontSize: 11, fontFamily: fontFamily.semibold },
|
||||
tdParticulars: { flex: 1, paddingHorizontal: 8, gap: 2 },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import { BottomSheet, Button, Card, Input, ScreenShell } from '../../../components';
|
||||
@@ -16,7 +16,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'ReKyc'>;
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IconName = React.ComponentProps<typeof Icon>['name'];
|
||||
type ChangeField = 'name' | 'mobile' | 'email' | 'address';
|
||||
|
||||
type ChangeOption = {
|
||||
@@ -102,10 +102,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.emptyIcon,
|
||||
{ backgroundColor: `${theme.colors.danger}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.danger, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="alert-circle-outline" size={28} color={theme.colors.danger} />
|
||||
<Icon name="alert-circle-outline" size={28} color={theme.colors.danger} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
|
||||
Account not found
|
||||
@@ -212,7 +212,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#F4F9FF', '#E8F2FE', '#F8FBFF']}
|
||||
colors={[theme.colors.surface, theme.colors.primarySurface, theme.colors.surface]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.heroCard, { borderColor: theme.colors.cardBorder }]}
|
||||
@@ -221,10 +221,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.heroIcon,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.primary} />
|
||||
<Icon name="shield-checkmark-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -242,7 +242,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.stepIndex,
|
||||
{ backgroundColor: `${theme.colors.primary}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.stepIndexText, { color: theme.colors.primary }]}>
|
||||
@@ -260,10 +260,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.sectionIcon,
|
||||
{ backgroundColor: `${theme.colors.info}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.info, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="card-outline" size={18} color={theme.colors.info} />
|
||||
<Icon name="card-outline" size={18} color={theme.colors.info} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -282,7 +282,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
keyboardType="number-pad"
|
||||
maxLength={14}
|
||||
placeholder="1234 5678 9012"
|
||||
leftAccessory={<Ionicons name="finger-print-outline" size={18} color={theme.colors.textMuted} />}
|
||||
leftAccessory={<Icon name="finger-print-outline" size={18} color={theme.colors.textMuted} />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -291,10 +291,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.sectionIcon,
|
||||
{ backgroundColor: `${theme.colors.warning}12` },
|
||||
{ backgroundColor: theme.tint(theme.colors.warning, 0.07) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="create-outline" size={18} color={theme.colors.warning} />
|
||||
<Icon name="create-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
@@ -317,12 +317,12 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
style={[
|
||||
styles.changeChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? `${theme.colors.primary}45` : theme.colors.cardBorder,
|
||||
backgroundColor: isSelected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.tint(theme.colors.primary, 0.27) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={option.icon as any}
|
||||
size={16}
|
||||
color={isSelected ? theme.colors.primary : theme.colors.textSecondary}
|
||||
@@ -348,7 +348,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={consentGiven ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={22}
|
||||
color={consentGiven ? theme.colors.success : theme.colors.textMuted}
|
||||
@@ -404,8 +404,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{/* Document upload */}
|
||||
<Card style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.success}12` }]}>
|
||||
<Ionicons name="cloud-upload-outline" size={18} color={theme.colors.success} />
|
||||
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.success, 0.07) }]}>
|
||||
<Icon name="cloud-upload-outline" size={18} color={theme.colors.success} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Upload documents</Text>
|
||||
@@ -424,13 +424,13 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
style={[
|
||||
styles.uploadRow,
|
||||
{
|
||||
backgroundColor: isUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
|
||||
borderColor: isUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
|
||||
backgroundColor: isUploaded ? theme.tint(theme.colors.success, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: isUploaded ? theme.tint(theme.colors.success, 0.27) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.uploadIcon, { backgroundColor: theme.colors.surface }]}>
|
||||
<Ionicons name={doc.icon} size={18} color={theme.colors.primary} />
|
||||
<Icon name={doc.icon} size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.uploadCopy}>
|
||||
<Text style={[styles.uploadLabel, { color: theme.colors.textPrimary }]}>{doc.label}</Text>
|
||||
@@ -438,7 +438,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{isUploaded ? 'Uploaded · tap to remove' : doc.hint}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isUploaded ? 'checkmark-circle' : 'add-circle-outline'}
|
||||
size={22}
|
||||
color={isUploaded ? theme.colors.success : theme.colors.primary}
|
||||
@@ -451,8 +451,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{/* Latest photograph */}
|
||||
<Card style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.info}12` }]}>
|
||||
<Ionicons name="camera-outline" size={18} color={theme.colors.info} />
|
||||
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.info, 0.07) }]}>
|
||||
<Icon name="camera-outline" size={18} color={theme.colors.info} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Latest photograph</Text>
|
||||
@@ -467,12 +467,12 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
style={[
|
||||
styles.photoTile,
|
||||
{
|
||||
backgroundColor: photoUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
|
||||
borderColor: photoUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
|
||||
backgroundColor: photoUploaded ? theme.tint(theme.colors.success, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: photoUploaded ? theme.tint(theme.colors.success, 0.27) : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={photoUploaded ? 'checkmark-circle' : 'person-circle-outline'}
|
||||
size={48}
|
||||
color={photoUploaded ? theme.colors.success : theme.colors.textMuted}
|
||||
@@ -486,8 +486,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{/* Self declaration */}
|
||||
<Card style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.warning}12` }]}>
|
||||
<Ionicons name="document-text-outline" size={18} color={theme.colors.warning} />
|
||||
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.warning, 0.07) }]}>
|
||||
<Icon name="document-text-outline" size={18} color={theme.colors.warning} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Self declaration</Text>
|
||||
@@ -508,7 +508,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={accepted ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={22}
|
||||
color={accepted ? theme.colors.success : theme.colors.textMuted}
|
||||
@@ -522,8 +522,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
{/* Video KYC option */}
|
||||
<Card style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="videocam-outline" size={18} color={theme.colors.primary} />
|
||||
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="videocam-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.sectionCopy}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Prefer a video call?</Text>
|
||||
@@ -536,13 +536,13 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
title="Start Video KYC"
|
||||
variant="secondary"
|
||||
onPress={() => account && navigation.navigate('VideoKyc', { accountNumber: account.accountNumber })}
|
||||
leftAccessory={<Ionicons name="videocam-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
leftAccessory={<Icon name="videocam-outline" size={18} color={theme.colors.textPrimary} />}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.footerCard}>
|
||||
<View style={styles.footerHeader}>
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
|
||||
<Icon name="time-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.footerTitle, { color: theme.colors.textPrimary }]}>
|
||||
Processing note
|
||||
</Text>
|
||||
@@ -553,7 +553,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
|
||||
<Button
|
||||
title="Submit re-KYC request"
|
||||
onPress={handleSubmit}
|
||||
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
</Card>
|
||||
<View style={{ height: 60 }} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, Pressable } from 'react-native';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
@@ -9,7 +9,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'VideoKyc'>;
|
||||
type IconName = React.ComponentProps<typeof Ionicons>['name'];
|
||||
type IconName = React.ComponentProps<typeof Icon>['name'];
|
||||
|
||||
type Slot = { id: string; label: string; sub: string };
|
||||
|
||||
@@ -50,15 +50,15 @@ export default function VideoKycScreen({ navigation }: Props) {
|
||||
<ScreenShell title="Video KYC" subtitle="Verify your identity over a video call" contentContainerStyle={styles.content}>
|
||||
{/* Hero */}
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={[styles.heroIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="videocam-outline" size={26} color={theme.colors.primary} />
|
||||
<View style={[styles.heroIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
|
||||
<Icon name="videocam-outline" size={26} color={theme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>Video-based Customer Identification</Text>
|
||||
<Text style={[styles.heroSub, { color: theme.colors.textSecondary }]}>
|
||||
A bank officer will verify your identity on a short, secure video call. Keep your PAN ready.
|
||||
</Text>
|
||||
<View style={styles.placeholderPreview}>
|
||||
<Ionicons name="person-circle-outline" size={56} color={theme.colors.textMuted} />
|
||||
<View style={[styles.placeholderPreview, { borderColor: theme.colors.cardBorderStrong }]}>
|
||||
<Icon name="person-circle-outline" size={56} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.previewText, { color: theme.colors.textMuted }]}>Camera preview</Text>
|
||||
</View>
|
||||
</Card>
|
||||
@@ -68,8 +68,8 @@ export default function VideoKycScreen({ navigation }: Props) {
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Before you start</Text>
|
||||
{CHECKLIST.map((item) => (
|
||||
<View key={item.id} style={styles.checkRow}>
|
||||
<View style={[styles.checkIcon, { backgroundColor: `${theme.colors.info}12` }]}>
|
||||
<Ionicons name={item.icon} size={16} color={theme.colors.info} />
|
||||
<View style={[styles.checkIcon, { backgroundColor: theme.tint(theme.colors.info, 0.07) }]}>
|
||||
<Icon name={item.icon} size={16} color={theme.colors.info} />
|
||||
</View>
|
||||
<Text style={[styles.checkText, { color: theme.colors.textPrimary }]}>{item.label}</Text>
|
||||
</View>
|
||||
@@ -88,12 +88,12 @@ export default function VideoKycScreen({ navigation }: Props) {
|
||||
style={[
|
||||
styles.slotRow,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
|
||||
backgroundColor: isSelected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
|
||||
borderColor: isSelected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={isSelected ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={isSelected ? theme.colors.primary : theme.colors.textMuted}
|
||||
@@ -112,7 +112,7 @@ export default function VideoKycScreen({ navigation }: Props) {
|
||||
onPress={() => setAgreed((value) => !value)}
|
||||
style={[styles.consentRow, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={agreed ? 'checkmark-circle' : 'ellipse-outline'}
|
||||
size={22}
|
||||
color={agreed ? theme.colors.success : theme.colors.textMuted}
|
||||
@@ -125,7 +125,7 @@ export default function VideoKycScreen({ navigation }: Props) {
|
||||
<Button
|
||||
title={selectedSlot === 'now' ? 'Start video call' : 'Book slot'}
|
||||
onPress={handleStart}
|
||||
leftAccessory={<Ionicons name="videocam" size={18} color={theme.colors.white} />}
|
||||
leftAccessory={<Icon name="videocam" size={18} color={theme.colors.white} />}
|
||||
/>
|
||||
<View style={{ height: 40 }} />
|
||||
</ScreenShell>
|
||||
@@ -144,7 +144,6 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(0,0,0,0.12)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
|
||||
@@ -6,10 +6,9 @@ import { downloadPdf } from '../../../services/files/pdfDownload';
|
||||
import type {
|
||||
CbsAccount,
|
||||
CbsAccountBalance,
|
||||
CbsMiniStatement,
|
||||
CbsPdfDocument,
|
||||
CbsStatementExportQuery,
|
||||
CbsStatementPage,
|
||||
CbsStatementQuery,
|
||||
MmidAccountsResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
@@ -49,34 +48,19 @@ export const accountsApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
/**
|
||||
* One page of an account's ledger.
|
||||
* The latest ten entries on an account, newest first.
|
||||
*
|
||||
* `fromDate` and `toDate` are dropped unless both are set. The bank
|
||||
* answers `INVALID_DATE_RANGE` when it gets exactly one, and a screen with
|
||||
* a half-filled range picker would otherwise send that on every keystroke.
|
||||
* Sending neither is the supported case — the bank then picks its own
|
||||
* recent window.
|
||||
*
|
||||
* Cached per distinct query, so paging back to a page already fetched does
|
||||
* not re-hit the bank, but `keepUnusedDataFor: 0` still drops the lot the
|
||||
* moment the screen unmounts: this response carries balances, and the
|
||||
* account data rule is that what is on screen was fetched for that screen.
|
||||
* No date filter exists or may be sent: the bank answers the globally
|
||||
* latest entries, however old. `keepUnusedDataFor: 0` because this carries
|
||||
* balances, and the account data rule is that what is on screen was
|
||||
* fetched for that screen — nothing persists past it.
|
||||
*/
|
||||
getStatement: builder.query<CbsStatementPage, CbsStatementQuery>({
|
||||
queryFn: ({ accountNumber, fromDate, toDate, page, size }) =>
|
||||
getMiniStatement: builder.query<CbsMiniStatement, string>({
|
||||
queryFn: (accountNumber) =>
|
||||
resolveLive(async () => {
|
||||
const hasRange = Boolean(fromDate && toDate);
|
||||
|
||||
const response = await apiClient.get<CbsStatementPage>(
|
||||
endpoints.accounts.statement,
|
||||
{
|
||||
params: {
|
||||
accountNumber,
|
||||
...(hasRange ? { fromDate, toDate } : {}),
|
||||
...(page === undefined ? {} : { page }),
|
||||
...(size === undefined ? {} : { size }),
|
||||
},
|
||||
},
|
||||
const response = await apiClient.get<CbsMiniStatement>(
|
||||
endpoints.accounts.miniStatement,
|
||||
{ params: { accountNumber } },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
@@ -140,44 +124,25 @@ export const accountsApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
/**
|
||||
* The statement for a range, as a PDF on disk.
|
||||
* Every entry in an inclusive range, as a password-protected PDF on disk.
|
||||
*
|
||||
* A mutation rather than a query even though it is a GET: it writes a file
|
||||
* and is fired by a button, and caching the resulting path would hand the
|
||||
* next caller a file that may since have been swept from the cache
|
||||
* directory. Same date-pair rule as `getStatement` — both or neither.
|
||||
* directory. All three parameters are required; an empty range still
|
||||
* returns a PDF saying so.
|
||||
*
|
||||
* The bank reuses the cached fetch behind `getStatement`, so exporting a
|
||||
* range the customer has just been reading costs no extra call upstream.
|
||||
* The fallback name follows the bank's own pattern —
|
||||
* `Statement_<last 4>_<from>_<to>.pdf` — for a server that omits
|
||||
* `Content-Disposition`.
|
||||
*/
|
||||
exportStatement: builder.mutation<CbsPdfDocument, CbsStatementExportQuery>({
|
||||
queryFn: ({ accountNumber, fromDate, toDate }) =>
|
||||
resolveLive(() => {
|
||||
const hasRange = Boolean(fromDate && toDate);
|
||||
|
||||
return downloadPdf({
|
||||
path: endpoints.accounts.statementExport,
|
||||
params: {
|
||||
accountNumber,
|
||||
...(hasRange ? { fromDate, toDate } : {}),
|
||||
},
|
||||
fallbackFileName: `statement-${accountNumber}.pdf`,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The last 10 entries as a short PDF, led by the current available balance
|
||||
* — the same figure an ATM mini-statement prints at the top. No date range
|
||||
* exists on this one; asking for a period is what `exportStatement` is for.
|
||||
*/
|
||||
downloadMiniStatement: builder.mutation<CbsPdfDocument, { accountNumber: string }>({
|
||||
queryFn: ({ accountNumber }) =>
|
||||
resolveLive(() =>
|
||||
downloadPdf({
|
||||
path: endpoints.accounts.miniStatement,
|
||||
params: { accountNumber },
|
||||
fallbackFileName: `mini-statement-${accountNumber}.pdf`,
|
||||
path: endpoints.accounts.statementExport,
|
||||
params: { accountNumber, fromDate, toDate },
|
||||
fallbackFileName: `Statement_${accountNumber.slice(-4)}_${fromDate}_${toDate}.pdf`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -188,7 +153,6 @@ export const {
|
||||
useGetAccountsQuery,
|
||||
useGetMmidAccountsQuery,
|
||||
useLazyGetAccountBalanceQuery,
|
||||
useGetStatementQuery,
|
||||
useGetMiniStatementQuery,
|
||||
useExportStatementMutation,
|
||||
useDownloadMiniStatementMutation,
|
||||
} = accountsApi;
|
||||
|
||||
@@ -1,112 +1,110 @@
|
||||
import type { CbsStatementTransaction } from '../../../types/cbs';
|
||||
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
|
||||
import {
|
||||
counterpartyDirection,
|
||||
counterpartyLabel,
|
||||
hasCounterparty,
|
||||
isCredit,
|
||||
statementAmount,
|
||||
statementBalance,
|
||||
statementDay,
|
||||
statementKey,
|
||||
statementRangeProblem,
|
||||
statementRemarks,
|
||||
statementTime,
|
||||
toStatementDate,
|
||||
} from './statement';
|
||||
|
||||
const row = (overrides: Partial<CbsStatementTransaction> = {}): CbsStatementTransaction => ({
|
||||
transactionDate: '2026-09-17T10:07:11',
|
||||
narration: 'Clearing - Outward Cheque',
|
||||
referenceNumber: 'TXN5123450032',
|
||||
transactionType: 'CLEARING_OUTWARD',
|
||||
drCr: 'DR',
|
||||
amount: 2693,
|
||||
balanceAfter: 9355,
|
||||
counterpartyName: 'Priya Nair',
|
||||
counterpartyAccountNumber: '91449252878981',
|
||||
counterpartyIfsc: 'HDFC0001234',
|
||||
counterpartyBankName: 'HDFC Bank',
|
||||
counterpartyVpa: null,
|
||||
/** The guide's own example row (§7). */
|
||||
const row = (overrides: Partial<CbsMiniStatementTransaction> = {}): CbsMiniStatementTransaction => ({
|
||||
transactionDate: '2026-10-14T10:30:00',
|
||||
referenceNumber: 'TEST001',
|
||||
remarks: 'UPI/TEST001/DR/TEST MERCHANT',
|
||||
transactionType: 'DEBIT',
|
||||
amount: 500.0,
|
||||
balanceAfter: 4500.0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* `transactionDate` gained a time component, and five counterparty fields
|
||||
* arrived alongside it. These pin the two things that broke or became possible
|
||||
* as a result: day grouping, which used to key on the whole value, and naming
|
||||
* the other side of an entry without ever printing a full account number.
|
||||
*/
|
||||
describe('statementDay', () => {
|
||||
it('takes the date off a value that now carries a time', () => {
|
||||
expect(statementDay(row())).toBe('2026-09-17');
|
||||
describe('toStatementDate', () => {
|
||||
it('reads the local calendar, with zero padding', () => {
|
||||
expect(toStatementDate(new Date(2026, 0, 5))).toBe('2026-01-05');
|
||||
expect(toStatementDate(new Date(2026, 9, 14, 23, 59))).toBe('2026-10-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statementRangeProblem', () => {
|
||||
const today = new Date(2026, 9, 14, 12);
|
||||
|
||||
it('accepts a normal range and a single day (both ends inclusive)', () => {
|
||||
expect(statementRangeProblem(new Date(2026, 9, 1), new Date(2026, 9, 14), today)).toBeNull();
|
||||
expect(statementRangeProblem(new Date(2026, 9, 14), new Date(2026, 9, 14), today)).toBeNull();
|
||||
});
|
||||
|
||||
it('buckets two entries booked on the same day together', () => {
|
||||
// The regression this exists for: keying on the raw `transactionDate` gave
|
||||
// each of these its own group, and the list drew a day header per row.
|
||||
expect(statementDay(row({ transactionDate: '2026-09-17T10:07:11' }))).toBe(
|
||||
statementDay(row({ transactionDate: '2026-09-17T18:44:02' })),
|
||||
it('refuses a missing date', () => {
|
||||
expect(statementRangeProblem(null, new Date(2026, 9, 14), today)).toMatch(/both/);
|
||||
expect(statementRangeProblem(new Date(2026, 9, 1), null, today)).toMatch(/both/);
|
||||
});
|
||||
|
||||
it('refuses an inverted range', () => {
|
||||
expect(statementRangeProblem(new Date(2026, 9, 14), new Date(2026, 9, 1), today)).toMatch(
|
||||
/after/,
|
||||
);
|
||||
});
|
||||
|
||||
it('is unchanged by a bank still sending a bare date', () => {
|
||||
expect(statementDay(row({ transactionDate: '2026-09-17' }))).toBe('2026-09-17');
|
||||
});
|
||||
|
||||
// Sliced, never parsed: `new Date('2026-09-17')` is read as UTC, which moves
|
||||
// the row to the 16th for every customer of this bank at +05:30.
|
||||
it('does not shift the day across a timezone', () => {
|
||||
expect(statementDay(row({ transactionDate: '2026-01-01T00:30:00' }))).toBe('2026-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('statementTime', () => {
|
||||
it('reads the booking time', () => {
|
||||
expect(statementTime(row())).toBe('10:07');
|
||||
});
|
||||
|
||||
it('is null rather than midnight when the bank sent no time', () => {
|
||||
expect(statementTime(row({ transactionDate: '2026-09-17' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('counterparty', () => {
|
||||
it('is absent for the channels that have no other party', () => {
|
||||
// All five fields are null together for these, by contract — so blank is
|
||||
// the right answer for them, not missing data.
|
||||
const cash = row({
|
||||
transactionType: 'CASH_DEPOSIT',
|
||||
counterpartyName: null,
|
||||
counterpartyAccountNumber: null,
|
||||
counterpartyIfsc: null,
|
||||
counterpartyBankName: null,
|
||||
counterpartyVpa: null,
|
||||
});
|
||||
|
||||
expect(hasCounterparty(cash)).toBe(false);
|
||||
expect(counterpartyLabel(cash)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the name, which is what a customer recognises', () => {
|
||||
expect(counterpartyLabel(row())).toBe('Priya Nair');
|
||||
});
|
||||
|
||||
it('falls back to the VPA for a UPI entry the bank did not name', () => {
|
||||
it('compares days, not instants, so a later time on the same day is fine', () => {
|
||||
expect(
|
||||
counterpartyLabel(
|
||||
row({
|
||||
transactionType: 'UPI',
|
||||
counterpartyName: null,
|
||||
counterpartyVpa: 'anita.iyer255@okhdfcbank',
|
||||
}),
|
||||
),
|
||||
).toBe('anita.iyer255@okhdfcbank');
|
||||
statementRangeProblem(new Date(2026, 9, 14, 18), new Date(2026, 9, 14, 9), today),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never prints a counterparty account number in full', () => {
|
||||
const label = counterpartyLabel(
|
||||
row({ counterpartyName: null, counterpartyVpa: null }),
|
||||
it('refuses an end date after today', () => {
|
||||
expect(statementRangeProblem(new Date(2026, 9, 1), new Date(2026, 9, 15), today)).toMatch(
|
||||
/future/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('direction', () => {
|
||||
it('maps CREDIT and DEBIT, case-insensitively', () => {
|
||||
expect(isCredit(row({ transactionType: 'CREDIT' }))).toBe(true);
|
||||
expect(isCredit(row({ transactionType: 'credit' }))).toBe(true);
|
||||
expect(isCredit(row({ transactionType: 'DEBIT' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a missing type as a debit rather than inventing income', () => {
|
||||
expect(isCredit(row({ transactionType: null }))).toBe(false);
|
||||
expect(isCredit(row({ transactionType: undefined }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statementDay and statementTime', () => {
|
||||
it('splits the bank local timestamp without converting it', () => {
|
||||
// No offset on the wire: 10:30 is shown as 10:30, not shifted from UTC.
|
||||
expect(statementDay(row())).toBe('2026-10-14');
|
||||
expect(statementTime(row())).toBe('10:30');
|
||||
});
|
||||
|
||||
it('returns null for a missing or malformed date rather than a fake one', () => {
|
||||
expect(statementDay(row({ transactionDate: null }))).toBeNull();
|
||||
expect(statementDay(row({ transactionDate: undefined }))).toBeNull();
|
||||
expect(statementTime(row({ transactionDate: '2026-10-14' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('falls back to the direction when remarks are absent', () => {
|
||||
expect(statementRemarks(row({ remarks: null }))).toBe('Debit');
|
||||
expect(statementRemarks(row({ remarks: ' ', transactionType: 'CREDIT' }))).toBe('Credit');
|
||||
expect(statementRemarks(row())).toBe('UPI/TEST001/DR/TEST MERCHANT');
|
||||
});
|
||||
|
||||
expect(label).toBe('••••8981');
|
||||
expect(label).not.toContain('91449252878981');
|
||||
it('keeps amounts positive and tolerates absent numbers', () => {
|
||||
expect(statementAmount(row())).toBe(500);
|
||||
expect(statementAmount(row({ amount: -500 }))).toBe(500);
|
||||
expect(statementAmount(row({ amount: null }))).toBeNull();
|
||||
expect(statementBalance(row({ balanceAfter: undefined }))).toBeNull();
|
||||
expect(statementBalance(row({ balanceAfter: 0 }))).toBe(0);
|
||||
});
|
||||
|
||||
it('says which way the money went', () => {
|
||||
expect(counterpartyDirection(row({ drCr: 'CR' }))).toBe('From');
|
||||
expect(counterpartyDirection(row({ drCr: 'DR' }))).toBe('To');
|
||||
it('builds a key with or without a reference number', () => {
|
||||
expect(statementKey(row(), 0)).toBe('TEST001-0');
|
||||
expect(statementKey(row({ referenceNumber: null }), 3)).toBe('row-3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +1,118 @@
|
||||
import type { CbsStatementTransaction } from '../../../types/cbs';
|
||||
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Rules for reading a statement row, kept free of transport and React imports
|
||||
* so they can be tested without standing up the API layer or a renderer.
|
||||
* Rules for reading a mini-statement row and for the statement PDF request,
|
||||
* kept free of transport and React imports so they can be tested without
|
||||
* standing up the API layer or a renderer.
|
||||
*
|
||||
* Every row field may be null or missing (Statement guide, 14 Oct 2026), so
|
||||
* nothing outside this file reads one directly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The calendar day a row was booked on, as `yyyy-MM-dd`.
|
||||
* A date in the `yyyy-MM-dd` the statement endpoint wants, read off the local
|
||||
* calendar.
|
||||
*
|
||||
* `transactionDate` used to be exactly this and is now
|
||||
* `yyyy-MM-ddTHH:mm:ss`, so anything that groups or compares rows by day has
|
||||
* to take the date part off first — otherwise every entry buckets on its own
|
||||
* and the list renders one day header per transaction.
|
||||
*
|
||||
* Sliced rather than parsed through `Date`: the value is already local time,
|
||||
* and `new Date(...)` on a bare `yyyy-MM-dd` is read as UTC, which would move
|
||||
* a row to the previous day for every customer of this bank at +05:30.
|
||||
* Deliberately not `toISOString().slice(0, 10)`. That converts to UTC first,
|
||||
* so for anyone east of Greenwich — every customer of this bank, at +05:30 —
|
||||
* a date picked as the 17th is sent as the 16th for most of the day, and a
|
||||
* range quietly loses its first day and gains a day at the other end.
|
||||
*/
|
||||
export const statementDay = (transaction: CbsStatementTransaction) =>
|
||||
transaction.transactionDate.slice(0, 10);
|
||||
export const toStatementDate = (date: Date) => {
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The time of day a row was booked, as `HH:mm`, or null for a bank still
|
||||
* sending a bare date.
|
||||
* Why a From/To pair cannot be sent, or null when it can.
|
||||
*
|
||||
* Nullable on purpose. The time arrived with a contract change, and a row that
|
||||
* predates it — or comes from a bank that truncates — must render without one
|
||||
* rather than showing `00:00`, which reads as midnight rather than as unknown.
|
||||
* Checked here so the customer hears it before a round trip; the bank applies
|
||||
* the same rule and answers `INVALID_DATE_RANGE`. Both ends are inclusive, so
|
||||
* a single day (`from` equal to `to`) is a valid range. A future end date is
|
||||
* refused because there is nothing there to print.
|
||||
*/
|
||||
export const statementTime = (transaction: CbsStatementTransaction) => {
|
||||
const time = transaction.transactionDate.slice(11, 16);
|
||||
export const statementRangeProblem = (from: Date | null, to: Date | null, today = new Date()) => {
|
||||
if (!from || !to) {
|
||||
return 'Choose both a From and a To date.';
|
||||
}
|
||||
|
||||
const fromDay = toStatementDate(from);
|
||||
const toDay = toStatementDate(to);
|
||||
|
||||
if (fromDay > toDay) {
|
||||
return 'The From date cannot be after the To date.';
|
||||
}
|
||||
|
||||
if (toDay > toStatementDate(today)) {
|
||||
return 'The To date cannot be in the future.';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** True for money arriving. Anything that is not `CREDIT` is read as a debit. */
|
||||
export const isCredit = (transaction: CbsMiniStatementTransaction) =>
|
||||
(transaction.transactionType ?? '').toUpperCase() === 'CREDIT';
|
||||
|
||||
/**
|
||||
* The calendar day a row was booked on, as `yyyy-MM-dd`, or null when the
|
||||
* bank sent none.
|
||||
*
|
||||
* Sliced rather than parsed through `Date`: the value is the bank's local time
|
||||
* with no offset, and must be shown as given rather than reinterpreted.
|
||||
*/
|
||||
export const statementDay = (transaction: CbsMiniStatementTransaction) => {
|
||||
const day = (transaction.transactionDate ?? '').slice(0, 10);
|
||||
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The time of day a row was booked, as `HH:mm`, or null when there is none.
|
||||
* Never shown as `00:00`, which would read as midnight rather than unknown.
|
||||
*/
|
||||
export const statementTime = (transaction: CbsMiniStatementTransaction) => {
|
||||
const time = (transaction.transactionDate ?? '').slice(11, 16);
|
||||
|
||||
return /^\d{2}:\d{2}$/.test(time) ? time : null;
|
||||
};
|
||||
|
||||
/** The narration to show, with a neutral fallback for a row that has none. */
|
||||
export const statementRemarks = (transaction: CbsMiniStatementTransaction) =>
|
||||
transaction.remarks?.trim() || (isCredit(transaction) ? 'Credit' : 'Debit');
|
||||
|
||||
/**
|
||||
* True when the bank named the other side of the entry.
|
||||
* The amount as a positive number. The bank promises a non-negative value;
|
||||
* `Math.abs` keeps a stray sign from doubling up with the direction prefix.
|
||||
*/
|
||||
export const statementAmount = (transaction: CbsMiniStatementTransaction) =>
|
||||
typeof transaction.amount === 'number' ? Math.abs(transaction.amount) : null;
|
||||
|
||||
/** The post-entry balance, or null when the bank omitted it. */
|
||||
export const statementBalance = (transaction: CbsMiniStatementTransaction) =>
|
||||
typeof transaction.balanceAfter === 'number' ? transaction.balanceAfter : null;
|
||||
|
||||
/**
|
||||
* A stable list key. The reference number when there is one; otherwise the
|
||||
* position, which is stable for a list the bank returns in a fixed order.
|
||||
*/
|
||||
export const statementKey = (transaction: CbsMiniStatementTransaction, index: number) =>
|
||||
transaction.referenceNumber ? `${transaction.referenceNumber}-${index}` : `row-${index}`;
|
||||
|
||||
/**
|
||||
* The customer-facing description of the statement PDF password.
|
||||
*
|
||||
* All four counterparty fields are null together for `ATM_WDL`, `POS` and
|
||||
* `CASH_DEPOSIT` — those have no other party's account — so an absent
|
||||
* counterparty is the right answer for them, not missing data, and the row
|
||||
* should simply omit the line rather than show a placeholder.
|
||||
* The rule is the bank's (guide §5): first four letters of the name on record
|
||||
* in capitals — padded with zeros when shorter — then the birth day and month
|
||||
* as DDMM. The app shows the rule and examples only; it never asks for the
|
||||
* date of birth, never works the password out, and never stores it.
|
||||
*/
|
||||
export const hasCounterparty = (transaction: CbsStatementTransaction) =>
|
||||
Boolean(
|
||||
transaction.counterpartyName ||
|
||||
transaction.counterpartyAccountNumber ||
|
||||
transaction.counterpartyVpa,
|
||||
);
|
||||
export const STATEMENT_PASSWORD_RULE =
|
||||
'The PDF is password protected. The password is the first four letters of your name in capitals, followed by your date and month of birth (DDMM).';
|
||||
|
||||
/**
|
||||
* One line naming the other party, or null when there is none to name.
|
||||
*
|
||||
* Prefers the name, because that is what a customer recognises. A UPI entry
|
||||
* falls back to the VPA, which is its only human-readable handle; everything
|
||||
* else falls back to the masked tail of the account number, never the whole
|
||||
* thing — a counterparty's full account number has no business on a list row.
|
||||
*/
|
||||
export const counterpartyLabel = (transaction: CbsStatementTransaction) => {
|
||||
if (transaction.counterpartyName) {
|
||||
return transaction.counterpartyName;
|
||||
}
|
||||
|
||||
if (transaction.counterpartyVpa) {
|
||||
return transaction.counterpartyVpa;
|
||||
}
|
||||
|
||||
const account = transaction.counterpartyAccountNumber;
|
||||
|
||||
return account ? `••••${account.slice(-4)}` : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the money went, or came from, as a prefix for the counterparty line.
|
||||
* `CR` means it arrived, so the counterparty is the sender.
|
||||
*/
|
||||
export const counterpartyDirection = (transaction: CbsStatementTransaction) =>
|
||||
transaction.drCr === 'CR' ? 'From' : 'To';
|
||||
export const STATEMENT_PASSWORD_EXAMPLES = [
|
||||
'Rahul Sharma, born 15 Aug → RAHU1508',
|
||||
'Al, born 05 Jan → AL000501',
|
||||
] as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import type { InsuranceCategory } from '../types';
|
||||
@@ -27,7 +27,7 @@ const InsuranceCategoryChipComponent = ({
|
||||
styles.chip,
|
||||
{
|
||||
borderColor: selected ? meta.accentColor : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? `${meta.accentColor}10` : theme.colors.surface,
|
||||
backgroundColor: selected ? theme.tint(meta.accentColor, 0.06) : theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -35,11 +35,11 @@ const InsuranceCategoryChipComponent = ({
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{
|
||||
backgroundColor: selected ? `${meta.accentColor}18` : theme.colors.surfaceMuted,
|
||||
backgroundColor: selected ? theme.tint(meta.accentColor, 0.09) : theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={meta.icon as any}
|
||||
size={18}
|
||||
color={selected ? meta.accentColor : theme.colors.textSecondary}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import { Icon } from '../../../components/Icon';
|
||||
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
@@ -31,7 +31,7 @@ const InsuranceDocumentItemComponent = ({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="document-text-outline" size={20} color={theme.colors.primary} />
|
||||
<Icon name="document-text-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{document.name}</Text>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user